diff --git a/.coveragerc b/.coveragerc
index c02a6fefe75..ceff3384202 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -6,11 +6,12 @@
source = homeassistant
omit =
homeassistant/__main__.py
+ homeassistant/helpers/backports/aiohttp_resolver.py
homeassistant/helpers/signal.py
homeassistant/scripts/__init__.py
+ homeassistant/scripts/benchmark/__init__.py
homeassistant/scripts/check_config.py
homeassistant/scripts/ensure_config.py
- homeassistant/scripts/benchmark/__init__.py
homeassistant/scripts/macos/__init__.py
# omit pieces of code that rely on external devices being present
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 83aa88140cc..2bdb6f99aad 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -4,7 +4,10 @@
"dockerFile": "../Dockerfile.dev",
"postCreateCommand": "script/setup",
"postStartCommand": "script/bootstrap",
- "containerEnv": { "DEVCONTAINER": "1" },
+ "containerEnv": {
+ "DEVCONTAINER": "1",
+ "PYTHONASYNCIODEBUG": "1"
+ },
// Port 5683 udp is used by Shelly integration
"appPort": ["8123:8123", "5683:5683/udp"],
"runArgs": ["-e", "GIT_EDITOR=code --wait"],
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index e1281a14b5c..7dd6f798eef 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -94,7 +94,7 @@ jobs:
id: generate_python_cache_key
run: >-
echo "key=venv-${{ env.CACHE_VERSION }}-${{
- hashFiles('requirements_test.txt') }}-${{
+ hashFiles('requirements_test.txt', 'requirements_test_pre_commit.txt') }}-${{
hashFiles('requirements_all.txt') }}-${{
hashFiles('homeassistant/package_constraints.txt') }}" >> $GITHUB_OUTPUT
- name: Generate partial pre-commit restore key
@@ -1088,25 +1088,17 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov (full coverage)
if: needs.info.outputs.test_full_suite == 'true'
- uses: Wandalen/wretry.action@v3.1.0
+ uses: codecov/codecov-action@v4.3.0
with:
- action: codecov/codecov-action@v4.3.0
- with: |
- fail_ci_if_error: true
- flags: full-suite
- token: ${{ env.CODECOV_TOKEN }}
- attempt_limit: 5
- attempt_delay: 30000
+ fail_ci_if_error: true
+ flags: full-suite
+ token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload coverage to Codecov (partial coverage)
if: needs.info.outputs.test_full_suite == 'false'
- uses: Wandalen/wretry.action@v3.1.0
+ uses: codecov/codecov-action@v4.3.0
with:
- action: codecov/codecov-action@v4.3.0
- with: |
- fail_ci_if_error: true
- token: ${{ env.CODECOV_TOKEN }}
- attempt_limit: 5
- attempt_delay: 30000
+ fail_ci_if_error: true
+ token: ${{ secrets.CODECOV_TOKEN }}
pytest-partial:
runs-on: ubuntu-22.04
@@ -1234,22 +1226,14 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov (full coverage)
if: needs.info.outputs.test_full_suite == 'true'
- uses: Wandalen/wretry.action@v3.1.0
+ uses: codecov/codecov-action@v4.3.0
with:
- action: codecov/codecov-action@v4.3.0
- with: |
- fail_ci_if_error: true
- flags: full-suite
- token: ${{ env.CODECOV_TOKEN }}
- attempt_limit: 5
- attempt_delay: 30000
+ fail_ci_if_error: true
+ flags: full-suite
+ token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload coverage to Codecov (partial coverage)
if: needs.info.outputs.test_full_suite == 'false'
- uses: Wandalen/wretry.action@v3.1.0
+ uses: codecov/codecov-action@v4.3.0
with:
- action: codecov/codecov-action@v4.3.0
- with: |
- fail_ci_if_error: true
- token: ${{ env.CODECOV_TOKEN }}
- attempt_limit: 5
- attempt_delay: 30000
+ fail_ci_if_error: true
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 760e7e20676..cd42fecbfa1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.3.5
+ rev: v0.3.7
hooks:
- id: ruff
args:
@@ -15,7 +15,7 @@ repos:
- --ignore-words-list=additionals,alle,alot,astroid,bund,caf,convencional,currenty,datas,farenheit,falsy,fo,frequence,haa,hass,iif,incomfort,ines,ist,nam,nd,pres,pullrequests,resset,rime,ser,serie,te,technik,ue,unsecure,vor,withing,zar
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
- exclude_types: [csv, json]
+ exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
diff --git a/.strict-typing b/.strict-typing
index b1d6df7c9b8..63a867e9c50 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -363,6 +363,7 @@ homeassistant.components.rest_command.*
homeassistant.components.rfxtrx.*
homeassistant.components.rhasspy.*
homeassistant.components.ridwell.*
+homeassistant.components.ring.*
homeassistant.components.rituals_perfume_genie.*
homeassistant.components.roku.*
homeassistant.components.romy.*
diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py
index 969fcc3529e..2a9525181f6 100644
--- a/homeassistant/auth/__init__.py
+++ b/homeassistant/auth/__init__.py
@@ -28,6 +28,7 @@ from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN, REFRESH_TOKEN_EXPIRA
from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config
from .models import AuthFlowResult
from .providers import AuthProvider, LoginFlow, auth_provider_from_config
+from .session import SessionManager
EVENT_USER_ADDED = "user_added"
EVENT_USER_UPDATED = "user_updated"
@@ -85,7 +86,7 @@ async def auth_manager_from_config(
module_hash[module.id] = module
manager = AuthManager(hass, store, provider_hash, module_hash)
- manager.async_setup()
+ await manager.async_setup()
return manager
@@ -180,9 +181,9 @@ class AuthManager:
self._remove_expired_job = HassJob(
self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback
)
+ self.session = SessionManager(hass, self)
- @callback
- def async_setup(self) -> None:
+ async def async_setup(self) -> None:
"""Set up the auth manager."""
hass = self.hass
hass.async_add_shutdown_job(
@@ -191,6 +192,7 @@ class AuthManager:
)
)
self._async_track_next_refresh_token_expiration()
+ await self.session.async_setup()
@property
def auth_providers(self) -> list[AuthProvider]:
diff --git a/homeassistant/auth/session.py b/homeassistant/auth/session.py
new file mode 100644
index 00000000000..88297b50d90
--- /dev/null
+++ b/homeassistant/auth/session.py
@@ -0,0 +1,205 @@
+"""Session auth module."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+import secrets
+from typing import TYPE_CHECKING, Final, TypedDict
+
+from aiohttp.web import Request
+from aiohttp_session import Session, get_session, new_session
+from cryptography.fernet import Fernet
+
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
+from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.storage import Store
+from homeassistant.util import dt as dt_util
+
+from .models import RefreshToken
+
+if TYPE_CHECKING:
+ from . import AuthManager
+
+
+TEMP_TIMEOUT = timedelta(minutes=5)
+TEMP_TIMEOUT_SECONDS = TEMP_TIMEOUT.total_seconds()
+
+SESSION_ID = "id"
+STORAGE_VERSION = 1
+STORAGE_KEY = "auth.session"
+
+
+class StrictConnectionTempSessionData:
+ """Data for accessing unauthorized resources for a short period of time."""
+
+ __slots__ = ("cancel_remove", "absolute_expiry")
+
+ def __init__(self, cancel_remove: CALLBACK_TYPE) -> None:
+ """Initialize the temp session data."""
+ self.cancel_remove: Final[CALLBACK_TYPE] = cancel_remove
+ self.absolute_expiry: Final[datetime] = dt_util.utcnow() + TEMP_TIMEOUT
+
+
+class StoreData(TypedDict):
+ """Data to store."""
+
+ unauthorized_sessions: dict[str, str]
+ key: str
+
+
+class SessionManager:
+ """Session manager."""
+
+ def __init__(self, hass: HomeAssistant, auth: AuthManager) -> None:
+ """Initialize the strict connection manager."""
+ self._auth = auth
+ self._hass = hass
+ self._temp_sessions: dict[str, StrictConnectionTempSessionData] = {}
+ self._strict_connection_sessions: dict[str, str] = {}
+ self._store = Store[StoreData](
+ hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True
+ )
+ self._key: str | None = None
+ self._refresh_token_revoke_callbacks: dict[str, CALLBACK_TYPE] = {}
+
+ @property
+ def key(self) -> str:
+ """Return the encryption key."""
+ if self._key is None:
+ self._key = Fernet.generate_key().decode()
+ self._async_schedule_save()
+ return self._key
+
+ async def async_validate_request_for_strict_connection_session(
+ self,
+ request: Request,
+ ) -> bool:
+ """Check if a request has a valid strict connection session."""
+ session = await get_session(request)
+ if session.new or session.empty:
+ return False
+ result = self.async_validate_strict_connection_session(session)
+ if result is False:
+ session.invalidate()
+ return result
+
+ @callback
+ def async_validate_strict_connection_session(
+ self,
+ session: Session,
+ ) -> bool:
+ """Validate a strict connection session."""
+ if not (session_id := session.get(SESSION_ID)):
+ return False
+
+ if token_id := self._strict_connection_sessions.get(session_id):
+ if self._auth.async_get_refresh_token(token_id):
+ return True
+ # refresh token is invalid, delete entry
+ self._strict_connection_sessions.pop(session_id)
+ self._async_schedule_save()
+
+ if data := self._temp_sessions.get(session_id):
+ if dt_util.utcnow() <= data.absolute_expiry:
+ return True
+ # session expired, delete entry
+ self._temp_sessions.pop(session_id).cancel_remove()
+
+ return False
+
+ @callback
+ def _async_register_revoke_token_callback(self, refresh_token_id: str) -> None:
+ """Register a callback to revoke all sessions for a refresh token."""
+ if refresh_token_id in self._refresh_token_revoke_callbacks:
+ return
+
+ @callback
+ def async_invalidate_auth_sessions() -> None:
+ """Invalidate all sessions for a refresh token."""
+ self._strict_connection_sessions = {
+ session_id: token_id
+ for session_id, token_id in self._strict_connection_sessions.items()
+ if token_id != refresh_token_id
+ }
+ self._async_schedule_save()
+
+ self._refresh_token_revoke_callbacks[refresh_token_id] = (
+ self._auth.async_register_revoke_token_callback(
+ refresh_token_id, async_invalidate_auth_sessions
+ )
+ )
+
+ async def async_create_session(
+ self,
+ request: Request,
+ refresh_token: RefreshToken,
+ ) -> None:
+ """Create new session for given refresh token.
+
+ Caller needs to make sure that the refresh token is valid.
+ By creating a session, we are implicitly revoking all other
+ sessions for the given refresh token as there is one refresh
+ token per device/user case.
+ """
+ self._strict_connection_sessions = {
+ session_id: token_id
+ for session_id, token_id in self._strict_connection_sessions.items()
+ if token_id != refresh_token.id
+ }
+
+ self._async_register_revoke_token_callback(refresh_token.id)
+ session_id = await self._async_create_new_session(request)
+ self._strict_connection_sessions[session_id] = refresh_token.id
+ self._async_schedule_save()
+
+ async def async_create_temp_unauthorized_session(self, request: Request) -> None:
+ """Create a temporary unauthorized session."""
+ session_id = await self._async_create_new_session(
+ request, max_age=int(TEMP_TIMEOUT_SECONDS)
+ )
+
+ @callback
+ def remove(_: datetime) -> None:
+ self._temp_sessions.pop(session_id, None)
+
+ self._temp_sessions[session_id] = StrictConnectionTempSessionData(
+ async_call_later(self._hass, TEMP_TIMEOUT_SECONDS, remove)
+ )
+
+ async def _async_create_new_session(
+ self,
+ request: Request,
+ *,
+ max_age: int | None = None,
+ ) -> str:
+ session_id = secrets.token_hex(64)
+
+ session = await new_session(request)
+ session[SESSION_ID] = session_id
+ if max_age is not None:
+ session.max_age = max_age
+ return session_id
+
+ @callback
+ def _async_schedule_save(self, delay: float = 1) -> None:
+ """Save sessions."""
+ self._store.async_delay_save(self._data_to_save, delay)
+
+ @callback
+ def _data_to_save(self) -> StoreData:
+ """Return the data to store."""
+ return StoreData(
+ unauthorized_sessions=self._strict_connection_sessions,
+ key=self.key,
+ )
+
+ async def async_setup(self) -> None:
+ """Set up session manager."""
+ data = await self._store.async_load()
+ if data is None:
+ return
+
+ self._key = data["key"]
+ self._strict_connection_sessions = data["unauthorized_sessions"]
+ for token_id in self._strict_connection_sessions.values():
+ self._async_register_revoke_token_callback(token_id)
diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py
index 496b6fa5fb1..73751daa6cb 100644
--- a/homeassistant/components/api/__init__.py
+++ b/homeassistant/components/api/__init__.py
@@ -284,7 +284,8 @@ class APIEntityStateView(HomeAssistantView):
# Read the state back for our response
status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK
- assert (state := hass.states.get(entity_id))
+ state = hass.states.get(entity_id)
+ assert state
resp = self.json(state.as_dict(), status_code)
resp.headers.add("Location", f"/api/states/{entity_id}")
diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py
index d0e605e7c1e..3d825cd99b5 100644
--- a/homeassistant/components/auth/__init__.py
+++ b/homeassistant/components/auth/__init__.py
@@ -162,6 +162,7 @@ from homeassistant.util import dt as dt_util
from . import indieauth, login_flow, mfa_setup_flow
DOMAIN = "auth"
+STRICT_CONNECTION_URL = "/auth/strict_connection/temp_token"
StoreResultType = Callable[[str, Credentials], str]
RetrieveResultType = Callable[[str, str], Credentials | None]
@@ -187,6 +188,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.register_view(RevokeTokenView())
hass.http.register_view(LinkUserView(retrieve_result))
hass.http.register_view(OAuth2AuthorizeCallbackView())
+ hass.http.register_view(StrictConnectionTempTokenView())
websocket_api.async_register_command(hass, websocket_current_user)
websocket_api.async_register_command(hass, websocket_create_long_lived_access_token)
@@ -195,8 +197,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
websocket_api.async_register_command(hass, websocket_delete_all_refresh_tokens)
websocket_api.async_register_command(hass, websocket_sign_path)
- await login_flow.async_setup(hass, store_result)
- await mfa_setup_flow.async_setup(hass)
+ login_flow.async_setup(hass, store_result)
+ mfa_setup_flow.async_setup(hass)
return True
@@ -260,10 +262,10 @@ class TokenView(HomeAssistantView):
return await RevokeTokenView.post(self, request) # type: ignore[arg-type]
if grant_type == "authorization_code":
- return await self._async_handle_auth_code(hass, data, request.remote)
+ return await self._async_handle_auth_code(hass, data, request)
if grant_type == "refresh_token":
- return await self._async_handle_refresh_token(hass, data, request.remote)
+ return await self._async_handle_refresh_token(hass, data, request)
return self.json(
{"error": "unsupported_grant_type"}, status_code=HTTPStatus.BAD_REQUEST
@@ -273,7 +275,7 @@ class TokenView(HomeAssistantView):
self,
hass: HomeAssistant,
data: MultiDictProxy[str],
- remote_addr: str | None,
+ request: web.Request,
) -> web.Response:
"""Handle authorization code request."""
client_id = data.get("client_id")
@@ -313,7 +315,7 @@ class TokenView(HomeAssistantView):
)
try:
access_token = hass.auth.async_create_access_token(
- refresh_token, remote_addr
+ refresh_token, request.remote
)
except InvalidAuthError as exc:
return self.json(
@@ -321,6 +323,7 @@ class TokenView(HomeAssistantView):
status_code=HTTPStatus.FORBIDDEN,
)
+ await hass.auth.session.async_create_session(request, refresh_token)
return self.json(
{
"access_token": access_token,
@@ -341,9 +344,9 @@ class TokenView(HomeAssistantView):
self,
hass: HomeAssistant,
data: MultiDictProxy[str],
- remote_addr: str | None,
+ request: web.Request,
) -> web.Response:
- """Handle authorization code request."""
+ """Handle refresh token request."""
client_id = data.get("client_id")
if client_id is not None and not indieauth.verify_client_id(client_id):
return self.json(
@@ -381,7 +384,7 @@ class TokenView(HomeAssistantView):
try:
access_token = hass.auth.async_create_access_token(
- refresh_token, remote_addr
+ refresh_token, request.remote
)
except InvalidAuthError as exc:
return self.json(
@@ -389,6 +392,7 @@ class TokenView(HomeAssistantView):
status_code=HTTPStatus.FORBIDDEN,
)
+ await hass.auth.session.async_create_session(request, refresh_token)
return self.json(
{
"access_token": access_token,
@@ -437,6 +441,20 @@ class LinkUserView(HomeAssistantView):
return self.json_message("User linked")
+class StrictConnectionTempTokenView(HomeAssistantView):
+ """View to get temporary strict connection token."""
+
+ url = STRICT_CONNECTION_URL
+ name = "api:auth:strict_connection:temp_token"
+ requires_auth = False
+
+ async def get(self, request: web.Request) -> web.Response:
+ """Get a temporary token and redirect to main page."""
+ hass = request.app[KEY_HASS]
+ await hass.auth.session.async_create_temp_unauthorized_session(request)
+ raise web.HTTPSeeOther(location="/")
+
+
@callback
def _create_auth_code_store() -> tuple[StoreResultType, RetrieveResultType]:
"""Create an in memory store."""
diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py
index 6c33d270f5f..5bad0dbb999 100644
--- a/homeassistant/components/auth/login_flow.py
+++ b/homeassistant/components/auth/login_flow.py
@@ -91,7 +91,7 @@ from homeassistant.components.http.ban import (
)
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.network import is_cloud_connection
from homeassistant.util.network import is_local
@@ -105,7 +105,8 @@ if TYPE_CHECKING:
from . import StoreResultType
-async def async_setup(
+@callback
+def async_setup(
hass: HomeAssistant, store_result: Callable[[str, Credentials], str]
) -> None:
"""Component to allow users to login."""
diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py
index aaa1dbaedbf..35d87cafd4f 100644
--- a/homeassistant/components/auth/mfa_setup_flow.py
+++ b/homeassistant/components/auth/mfa_setup_flow.py
@@ -62,7 +62,8 @@ class MfaFlowManager(data_entry_flow.FlowManager):
return result
-async def async_setup(hass: HomeAssistant) -> None:
+@callback
+def async_setup(hass: HomeAssistant) -> None:
"""Init mfa setup flow manager."""
hass.data[DATA_SETUP_FLOW_MGR] = MfaFlowManager(hass)
diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py
index 785d5849d74..afc8f9aba10 100644
--- a/homeassistant/components/automation/__init__.py
+++ b/homeassistant/components/automation/__init__.py
@@ -604,18 +604,20 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
)
if enable_automation:
- await self.async_enable()
+ await self._async_enable()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on and update the state."""
- await self.async_enable()
+ await self._async_enable()
+ self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
if CONF_STOP_ACTIONS in kwargs:
- await self.async_disable(kwargs[CONF_STOP_ACTIONS])
+ await self._async_disable(kwargs[CONF_STOP_ACTIONS])
else:
- await self.async_disable()
+ await self._async_disable()
+ self.async_write_ha_state()
async def async_trigger(
self,
@@ -743,7 +745,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
- await self.async_disable()
+ await self._async_disable()
async def _async_enable_automation(self, event: Event) -> None:
"""Start automation on startup."""
@@ -752,31 +754,34 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
return
self._async_detach_triggers = await self._async_attach_triggers(True)
+ self.async_write_ha_state()
- async def async_enable(self) -> None:
+ async def _async_enable(self) -> None:
"""Enable this automation entity.
- This method is a coroutine.
+ This method is not expected to write state to the
+ state machine.
"""
if self._is_enabled:
return
self._is_enabled = True
-
# HomeAssistant is starting up
if self.hass.state is not CoreState.not_running:
self._async_detach_triggers = await self._async_attach_triggers(False)
- self.async_write_ha_state()
return
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED,
self._async_enable_automation,
)
- self.async_write_ha_state()
- async def async_disable(self, stop_actions: bool = DEFAULT_STOP_ACTIONS) -> None:
- """Disable the automation entity."""
+ async def _async_disable(self, stop_actions: bool = DEFAULT_STOP_ACTIONS) -> None:
+ """Disable the automation entity.
+
+ This method is not expected to write state to the
+ state machine.
+ """
if not self._is_enabled and not self.action_script.runs:
return
@@ -789,18 +794,31 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
if stop_actions:
await self.action_script.async_stop()
- self.async_write_ha_state()
-
def _log_callback(self, level: int, msg: str, **kwargs: Any) -> None:
"""Log helper callback."""
self._logger.log(level, "%s %s", msg, self.name, **kwargs)
+ async def _async_trigger_if_enabled(
+ self,
+ run_variables: dict[str, Any],
+ context: Context | None = None,
+ skip_condition: bool = False,
+ ) -> ScriptRunResult | None:
+ """Trigger automation if enabled.
+
+ If the trigger starts but has a delay, the automation will be triggered
+ when the delay has passed so we need to make sure its still enabled before
+ executing the action.
+ """
+ if not self._is_enabled:
+ return None
+ return await self.async_trigger(run_variables, context, skip_condition)
+
async def _async_attach_triggers(
self, home_assistant_start: bool
) -> Callable[[], None] | None:
"""Set up the triggers."""
this = None
- self.async_write_ha_state()
if state := self.hass.states.get(self.entity_id):
this = state.as_dict()
variables = {"this": this}
@@ -818,7 +836,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
return await async_initialize_triggers(
self.hass,
self._trigger_config,
- self.async_trigger,
+ self._async_trigger_if_enabled,
DOMAIN,
str(self.name),
self._log_callback,
diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py
index deda8f466a6..537019fb9c1 100644
--- a/homeassistant/components/azure_devops/__init__.py
+++ b/homeassistant/components/azure_devops/__init__.py
@@ -118,8 +118,8 @@ class AzureDevOpsEntity(CoordinatorEntity[DataUpdateCoordinator[list[DevOpsBuild
"""Initialize the Azure DevOps entity."""
super().__init__(coordinator)
self.entity_description = entity_description
- self._attr_unique_id: str = "_".join(
- [entity_description.organization, entity_description.key]
+ self._attr_unique_id: str = (
+ f"{entity_description.organization}_{entity_description.key}"
)
self._organization: str = entity_description.organization
self._project_name: str = entity_description.project.name
diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py
index d6a80e8fa8f..7e220bd46f8 100644
--- a/homeassistant/components/balboa/__init__.py
+++ b/homeassistant/components/balboa/__init__.py
@@ -18,7 +18,13 @@ from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME, DOMAIN
_LOGGER = logging.getLogger(__name__)
-PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.FAN, Platform.LIGHT]
+PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.CLIMATE,
+ Platform.FAN,
+ Platform.LIGHT,
+ Platform.SELECT,
+]
KEEP_ALIVE_INTERVAL = timedelta(minutes=1)
diff --git a/homeassistant/components/balboa/icons.json b/homeassistant/components/balboa/icons.json
index 7261f71bd00..7454366f692 100644
--- a/homeassistant/components/balboa/icons.json
+++ b/homeassistant/components/balboa/icons.json
@@ -27,6 +27,11 @@
"off": "mdi:pump-off"
}
}
+ },
+ "select": {
+ "temperature_range": {
+ "default": "mdi:thermometer-lines"
+ }
}
}
}
diff --git a/homeassistant/components/balboa/select.py b/homeassistant/components/balboa/select.py
new file mode 100644
index 00000000000..3fdd8c4d014
--- /dev/null
+++ b/homeassistant/components/balboa/select.py
@@ -0,0 +1,52 @@
+"""Support for Spa Client selects."""
+
+from pybalboa import SpaClient, SpaControl
+from pybalboa.enums import LowHighRange
+
+from homeassistant.components.select import SelectEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .entity import BalboaEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up the spa select entity."""
+ spa: SpaClient = hass.data[DOMAIN][entry.entry_id]
+ async_add_entities([BalboaTempRangeSelectEntity(spa.temperature_range)])
+
+
+class BalboaTempRangeSelectEntity(BalboaEntity, SelectEntity):
+ """Representation of a Temperature Range select."""
+
+ _attr_icon = "mdi:thermometer-lines"
+ _attr_name = "Temperature range"
+ _attr_unique_id = "temperature_range"
+ _attr_translation_key = "temperature_range"
+ _attr_options = [
+ LowHighRange.LOW.name.lower(),
+ LowHighRange.HIGH.name.lower(),
+ ]
+
+ def __init__(self, control: SpaControl) -> None:
+ """Initialise the select."""
+ super().__init__(control.client, "TempHiLow")
+ self._control = control
+
+ @property
+ def current_option(self) -> str | None:
+ """Return current select option."""
+ if self._control.state == LowHighRange.HIGH:
+ return LowHighRange.HIGH.name.lower()
+ return LowHighRange.LOW.name.lower()
+
+ async def async_select_option(self, option: str) -> None:
+ """Select temperature range high/low mode."""
+ if option == LowHighRange.HIGH.name.lower():
+ await self._client.set_temperature_range(LowHighRange.HIGH)
+ else:
+ await self._client.set_temperature_range(LowHighRange.LOW)
diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json
index 3c8f82764d4..6ced7dfd8c3 100644
--- a/homeassistant/components/balboa/strings.json
+++ b/homeassistant/components/balboa/strings.json
@@ -65,6 +65,15 @@
"only_light": {
"name": "Light"
}
+ },
+ "select": {
+ "temperature_range": {
+ "name": "Temperature range",
+ "state": {
+ "low": "Low",
+ "high": "High"
+ }
+ }
}
}
}
diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py
index de85e6309f9..4049a656caf 100644
--- a/homeassistant/components/citybikes/sensor.py
+++ b/homeassistant/components/citybikes/sensor.py
@@ -201,9 +201,9 @@ async def async_setup_platform(
if radius > dist or stations_list.intersection((station_id, station_uid)):
if name:
- uid = "_".join([network.network_id, name, station_id])
+ uid = f"{network.network_id}_{name}_{station_id}"
else:
- uid = "_".join([network.network_id, station_id])
+ uid = f"{network.network_id}_{station_id}"
entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, uid, hass=hass)
devices.append(CityBikesStation(network, station_id, entity_id))
diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py
index c790b8596a9..b1bf78063c7 100644
--- a/homeassistant/components/climate/const.py
+++ b/homeassistant/components/climate/const.py
@@ -33,7 +33,7 @@ class HVACMode(StrEnum):
# Device is in Dry/Humidity mode
DRY = "dry"
- # Only the fan is on, not fan and another mode like cool
+ # Only the fan is on, not fan and another mode like cool
FAN_ONLY = "fan_only"
diff --git a/homeassistant/components/color_extractor/config_flow.py b/homeassistant/components/color_extractor/config_flow.py
index aacb07d8982..aab56eb9537 100644
--- a/homeassistant/components/color_extractor/config_flow.py
+++ b/homeassistant/components/color_extractor/config_flow.py
@@ -18,10 +18,6 @@ class ColorExtractorConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
-
if user_input is not None:
return self.async_create_entry(title=DEFAULT_NAME, data={})
-
return self.async_show_form(step_id="user")
diff --git a/homeassistant/components/color_extractor/manifest.json b/homeassistant/components/color_extractor/manifest.json
index c87ac2540a6..a86adaac495 100644
--- a/homeassistant/components/color_extractor/manifest.json
+++ b/homeassistant/components/color_extractor/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@GenericStudent"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/color_extractor",
- "requirements": ["colorthief==0.2.1"]
+ "requirements": ["colorthief==0.2.1"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/color_extractor/strings.json b/homeassistant/components/color_extractor/strings.json
index f66c448f7c2..e501879e881 100644
--- a/homeassistant/components/color_extractor/strings.json
+++ b/homeassistant/components/color_extractor/strings.json
@@ -4,9 +4,6 @@
"user": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
}
- },
- "abort": {
- "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
}
},
"services": {
diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py
index d4533b2fcc8..d0da07da37c 100644
--- a/homeassistant/components/energy/data.py
+++ b/homeassistant/components/energy/data.py
@@ -136,6 +136,9 @@ class DeviceConsumption(TypedDict):
# This is an ever increasing value
stat_consumption: str
+ # An optional custom name for display in energy graphs
+ name: str | None
+
class EnergyPreferences(TypedDict):
"""Dictionary holding the energy data."""
@@ -287,6 +290,7 @@ ENERGY_SOURCE_SCHEMA = vol.All(
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
{
vol.Required("stat_consumption"): str,
+ vol.Optional("name"): str,
}
)
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index 028fb28f01b..d711314cabb 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20240404.1"]
+ "requirements": ["home-assistant-frontend==20240404.2"]
}
diff --git a/homeassistant/components/fyta/config_flow.py b/homeassistant/components/fyta/config_flow.py
index 8419352dc44..e11c024ec1f 100644
--- a/homeassistant/components/fyta/config_flow.py
+++ b/homeassistant/components/fyta/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from collections.abc import Mapping
import logging
from typing import Any
@@ -13,7 +14,7 @@ from fyta_cli.fyta_exceptions import (
)
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import DOMAIN
@@ -30,36 +31,70 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Fyta."""
VERSION = 1
+ _entry: ConfigEntry | None = None
+
+ async def async_auth(self, user_input: Mapping[str, Any]) -> dict[str, str]:
+ """Reusable Auth Helper."""
+ fyta = FytaConnector(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
+
+ try:
+ await fyta.login()
+ except FytaConnectionError:
+ return {"base": "cannot_connect"}
+ except FytaAuthentificationError:
+ return {"base": "invalid_auth"}
+ except FytaPasswordError:
+ return {"base": "invalid_auth", CONF_PASSWORD: "password_error"}
+ except Exception as e: # pylint: disable=broad-except
+ _LOGGER.error(e)
+ return {"base": "unknown"}
+ finally:
+ await fyta.client.close()
+
+ return {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
-
errors = {}
if user_input:
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
- fyta = FytaConnector(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
-
- try:
- await fyta.login()
- except FytaConnectionError:
- errors["base"] = "cannot_connect"
- except FytaAuthentificationError:
- errors["base"] = "invalid_auth"
- except FytaPasswordError:
- errors["base"] = "invalid_auth"
- errors[CONF_PASSWORD] = "password_error"
- except Exception: # pylint: disable=broad-except
- errors["base"] = "unknown"
- else:
+ if not (errors := await self.async_auth(user_input)):
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)
- finally:
- await fyta.client.close()
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle flow upon an API authentication error."""
+ self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reauthorization flow."""
+ errors = {}
+ assert self._entry is not None
+
+ if user_input and not (errors := await self.async_auth(user_input)):
+ return self.async_update_reload_and_abort(
+ self._entry, data={**self._entry.data, **user_input}
+ )
+
+ data_schema = self.add_suggested_values_to_schema(
+ DATA_SCHEMA,
+ {CONF_USERNAME: self._entry.data[CONF_USERNAME], **(user_input or {})},
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=data_schema,
+ errors=errors,
+ )
diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py
index c132ee75e72..65bd0cb532c 100644
--- a/homeassistant/components/fyta/coordinator.py
+++ b/homeassistant/components/fyta/coordinator.py
@@ -13,7 +13,7 @@ from fyta_cli.fyta_exceptions import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -52,4 +52,4 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, dict[str, Any]]]):
except FytaConnectionError as ex:
raise ConfigEntryNotReady from ex
except (FytaAuthentificationError, FytaPasswordError) as ex:
- raise ConfigEntryError from ex
+ raise ConfigEntryAuthFailed from ex
diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json
index 6d4fe68a86c..3df851489bc 100644
--- a/homeassistant/components/fyta/strings.json
+++ b/homeassistant/components/fyta/strings.json
@@ -8,8 +8,19 @@
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
+ },
+ "reauth_confirm": {
+ "description": "Update your credentials for FYTA API",
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ }
}
},
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ },
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py
index ce3b5b05ffe..972942caf52 100644
--- a/homeassistant/components/hassio/__init__.py
+++ b/homeassistant/components/hassio/__init__.py
@@ -196,7 +196,7 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.slug]),
+ vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [VALID_ADDON_SLUG]),
}
)
@@ -211,7 +211,7 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.slug]),
+ vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [VALID_ADDON_SLUG]),
}
)
diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py
index 6d6faa6fe75..ed6e47145dd 100644
--- a/homeassistant/components/hassio/ingress.py
+++ b/homeassistant/components/hassio/ingress.py
@@ -197,7 +197,6 @@ class HassIOIngress(HomeAssistantView):
content_type or simple_response.content_type
):
simple_response.enable_compression()
- await simple_response.prepare(request)
return simple_response
# Stream response
diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py
index 8458d7eaac2..63ed3d5c8a3 100644
--- a/homeassistant/components/hassio/repairs.py
+++ b/homeassistant/components/hassio/repairs.py
@@ -22,7 +22,7 @@ from .const import (
from .handler import async_apply_suggestion
from .issues import Issue, Suggestion
-SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"}
+SUGGESTION_CONFIRMATION_REQUIRED = {"system_adopt_data_disk", "system_execute_reboot"}
EXTRA_PLACEHOLDERS = {
"issue_mount_mount_failed": {
diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json
index 77ef408cafe..63c1da4bfd8 100644
--- a/homeassistant/components/hassio/strings.json
+++ b/homeassistant/components/hassio/strings.json
@@ -51,8 +51,15 @@
"title": "Multiple data disks detected",
"fix_flow": {
"step": {
- "system_rename_data_disk": {
- "description": "`{reference}` is a filesystem with the name hassos-data and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system."
+ "fix_menu": {
+ "description": "`{reference}` is a filesystem with the name hassos-data and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the 'Rename' option to rename the filesystem to prevent this. Use the 'Adopt' option to make that your data disk and rename the existing one. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system.",
+ "menu_options": {
+ "system_rename_data_disk": "Rename",
+ "system_adopt_data_disk": "Adopt"
+ }
+ },
+ "system_adopt_data_disk": {
+ "description": "This fix will initiate a system reboot which will make Home Assistant and all the Add-ons inaccessible for a brief period. After the reboot `{reference}` will be the data disk of Home Assistant and your existing data disk will be renamed and ignored."
}
},
"abort": {
diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py
index 4f2c593d38e..c9a58f29215 100644
--- a/homeassistant/components/holiday/__init__.py
+++ b/homeassistant/components/holiday/__init__.py
@@ -2,15 +2,36 @@
from __future__ import annotations
+from functools import partial
+
+from holidays import country_holidays
+
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
+from homeassistant.const import CONF_COUNTRY, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.setup import SetupPhases, async_pause_setup
+
+from .const import CONF_PROVINCE
PLATFORMS: list[Platform] = [Platform.CALENDAR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Holiday from a config entry."""
+ country: str = entry.data[CONF_COUNTRY]
+ province: str | None = entry.data.get(CONF_PROVINCE)
+
+ # We only import here to ensure that that its not imported later
+ # in the event loop since the platforms will call country_holidays
+ # which loads python code from disk.
+ with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES):
+ # import executor job is used here because multiple integrations use
+ # the holidays library and it is not thread safe to import it in parallel
+ # https://github.com/python/cpython/issues/83065
+ await hass.async_add_import_executor_job(
+ partial(country_holidays, country, subdiv=province)
+ )
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py
index e89031cb265..3e5f7333cbc 100644
--- a/homeassistant/components/http/__init__.py
+++ b/homeassistant/components/http/__init__.py
@@ -10,7 +10,8 @@ import os
import socket
import ssl
from tempfile import NamedTemporaryFile
-from typing import Any, Final, TypedDict, cast
+from typing import Any, Final, Required, TypedDict, cast
+from urllib.parse import quote_plus, urljoin
from aiohttp import web
from aiohttp.abc import AbstractStreamWriter
@@ -30,8 +31,20 @@ from yarl import URL
from homeassistant.components.network import async_get_source_ip
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT
-from homeassistant.core import Event, HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.core import (
+ Event,
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+ callback,
+)
+from homeassistant.exceptions import (
+ HomeAssistantError,
+ ServiceValidationError,
+ Unauthorized,
+ UnknownUser,
+)
from homeassistant.helpers import storage
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.http import (
@@ -53,9 +66,13 @@ from homeassistant.util import dt as dt_util, ssl as ssl_util
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.json import json_loads
-from .auth import async_setup_auth
+from .auth import async_setup_auth, async_sign_path
from .ban import setup_bans
-from .const import KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401
+from .const import ( # noqa: F401
+ KEY_HASS_REFRESH_TOKEN_ID,
+ KEY_HASS_USER,
+ StrictConnectionMode,
+)
from .cors import setup_cors
from .decorators import require_admin # noqa: F401
from .forwarded import async_setup_forwarded
@@ -80,6 +97,7 @@ CONF_TRUSTED_PROXIES: Final = "trusted_proxies"
CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold"
CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled"
CONF_SSL_PROFILE: Final = "ssl_profile"
+CONF_STRICT_CONNECTION: Final = "strict_connection"
SSL_MODERN: Final = "modern"
SSL_INTERMEDIATE: Final = "intermediate"
@@ -129,6 +147,9 @@ HTTP_SCHEMA: Final = vol.All(
[SSL_INTERMEDIATE, SSL_MODERN]
),
vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean,
+ vol.Optional(
+ CONF_STRICT_CONNECTION, default=StrictConnectionMode.DISABLED
+ ): vol.In([e.value for e in StrictConnectionMode]),
}
),
)
@@ -152,6 +173,7 @@ class ConfData(TypedDict, total=False):
login_attempts_threshold: int
ip_ban_enabled: bool
ssl_profile: str
+ strict_connection: Required[StrictConnectionMode]
@bind_hass
@@ -218,6 +240,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
login_threshold=login_threshold,
is_ban_enabled=is_ban_enabled,
use_x_frame_options=use_x_frame_options,
+ strict_connection_non_cloud=conf[CONF_STRICT_CONNECTION],
)
async def stop_server(event: Event) -> None:
@@ -247,6 +270,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
local_ip, host, server_port, ssl_certificate is not None
)
+ _setup_services(hass, conf)
return True
@@ -331,6 +355,7 @@ class HomeAssistantHTTP:
login_threshold: int,
is_ban_enabled: bool,
use_x_frame_options: bool,
+ strict_connection_non_cloud: StrictConnectionMode,
) -> None:
"""Initialize the server."""
self.app[KEY_HASS] = self.hass
@@ -347,7 +372,7 @@ class HomeAssistantHTTP:
if is_ban_enabled:
setup_bans(self.hass, self.app, login_threshold)
- await async_setup_auth(self.hass, self.app)
+ await async_setup_auth(self.hass, self.app, strict_connection_non_cloud)
setup_headers(self.app, use_x_frame_options)
setup_cors(self.app, cors_origins)
@@ -577,3 +602,59 @@ async def start_http_server_and_save_config(
]
store.async_delay_save(lambda: conf, SAVE_DELAY)
+
+
+@callback
+def _setup_services(hass: HomeAssistant, conf: ConfData) -> None:
+ """Set up services for HTTP component."""
+
+ async def create_temporary_strict_connection_url(
+ call: ServiceCall,
+ ) -> ServiceResponse:
+ """Create a strict connection url and return it."""
+ # Copied form homeassistant/helpers/service.py#_async_admin_handler
+ # as the helper supports no responses yet
+ if call.context.user_id:
+ user = await hass.auth.async_get_user(call.context.user_id)
+ if user is None:
+ raise UnknownUser(context=call.context)
+ if not user.is_admin:
+ raise Unauthorized(context=call.context)
+
+ if conf[CONF_STRICT_CONNECTION] is StrictConnectionMode.DISABLED:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="strict_connection_not_enabled_non_cloud",
+ )
+
+ try:
+ url = get_url(hass, prefer_external=True, allow_internal=False)
+ except NoURLAvailableError as ex:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_external_url_available",
+ ) from ex
+
+ # to avoid circular import
+ # pylint: disable-next=import-outside-toplevel
+ from homeassistant.components.auth import STRICT_CONNECTION_URL
+
+ path = async_sign_path(
+ hass,
+ STRICT_CONNECTION_URL,
+ datetime.timedelta(hours=1),
+ use_content_user=True,
+ )
+ url = urljoin(url, path)
+
+ return {
+ "url": f"https://login.home-assistant.io?u={quote_plus(url)}",
+ "direct_url": url,
+ }
+
+ hass.services.async_register(
+ DOMAIN,
+ "create_temporary_strict_connection_url",
+ create_temporary_strict_connection_url,
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py
index 2073c998384..1eb74289089 100644
--- a/homeassistant/components/http/auth.py
+++ b/homeassistant/components/http/auth.py
@@ -4,14 +4,18 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import timedelta
+from http import HTTPStatus
from ipaddress import ip_address
import logging
+import os
import secrets
import time
from typing import Any, Final
from aiohttp import hdrs
-from aiohttp.web import Application, Request, StreamResponse, middleware
+from aiohttp.web import Application, Request, Response, StreamResponse, middleware
+from aiohttp.web_exceptions import HTTPBadRequest
+from aiohttp_session import session_middleware
import jwt
from jwt import api_jws
from yarl import URL
@@ -27,7 +31,13 @@ from homeassistant.helpers.network import is_cloud_connection
from homeassistant.helpers.storage import Store
from homeassistant.util.network import is_local
-from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER
+from .const import (
+ KEY_AUTHENTICATED,
+ KEY_HASS_REFRESH_TOKEN_ID,
+ KEY_HASS_USER,
+ StrictConnectionMode,
+)
+from .session import HomeAssistantCookieStorage
_LOGGER = logging.getLogger(__name__)
@@ -39,6 +49,10 @@ SAFE_QUERY_PARAMS: Final = ["height", "width"]
STORAGE_VERSION = 1
STORAGE_KEY = "http.auth"
CONTENT_USER_NAME = "Home Assistant Content"
+STRICT_CONNECTION_EXCLUDED_PATH = "/api/webhook/"
+STRICT_CONNECTION_STATIC_PAGE = os.path.join(
+ os.path.dirname(__file__), "strict_connection_static_page.html"
+)
@callback
@@ -48,13 +62,16 @@ def async_sign_path(
expiration: timedelta,
*,
refresh_token_id: str | None = None,
+ use_content_user: bool = False,
) -> str:
"""Sign a path for temporary access without auth header."""
if (secret := hass.data.get(DATA_SIGN_SECRET)) is None:
secret = hass.data[DATA_SIGN_SECRET] = secrets.token_hex()
if refresh_token_id is None:
- if connection := websocket_api.current_connection.get():
+ if use_content_user:
+ refresh_token_id = hass.data[STORAGE_KEY]
+ elif connection := websocket_api.current_connection.get():
refresh_token_id = connection.refresh_token_id
elif (
request := current_request.get()
@@ -114,7 +131,11 @@ def async_user_not_allowed_do_auth(
return "User cannot authenticate remotely"
-async def async_setup_auth(hass: HomeAssistant, app: Application) -> None:
+async def async_setup_auth(
+ hass: HomeAssistant,
+ app: Application,
+ strict_connection_mode_non_cloud: StrictConnectionMode,
+) -> None:
"""Create auth middleware for the app."""
store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
if (data := await store.async_load()) is None:
@@ -135,6 +156,16 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None:
await store.async_save(data)
hass.data[STORAGE_KEY] = refresh_token.id
+ strict_connection_static_file_content = None
+ if strict_connection_mode_non_cloud is StrictConnectionMode.STATIC_PAGE:
+
+ def read_static_page() -> str:
+ with open(STRICT_CONNECTION_STATIC_PAGE, encoding="utf-8") as file:
+ return file.read()
+
+ strict_connection_static_file_content = await hass.async_add_executor_job(
+ read_static_page
+ )
@callback
def async_validate_auth_header(request: Request) -> bool:
@@ -224,6 +255,22 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None:
authenticated = True
auth_type = "signed request"
+ if (
+ not authenticated
+ and strict_connection_mode_non_cloud is not StrictConnectionMode.DISABLED
+ and not request.path.startswith(STRICT_CONNECTION_EXCLUDED_PATH)
+ and not await hass.auth.session.async_validate_request_for_strict_connection_session(
+ request
+ )
+ and (
+ resp := _async_perform_action_on_non_local(
+ request, strict_connection_static_file_content
+ )
+ )
+ is not None
+ ):
+ return resp
+
if authenticated and _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
"Authenticated %s for %s using %s",
@@ -235,4 +282,43 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None:
request[KEY_AUTHENTICATED] = authenticated
return await handler(request)
+ app.middlewares.append(session_middleware(HomeAssistantCookieStorage(hass)))
app.middlewares.append(auth_middleware)
+
+
+@callback
+def _async_perform_action_on_non_local(
+ request: Request,
+ strict_connection_static_file_content: str | None,
+) -> StreamResponse | None:
+ """Perform strict connection mode action if the request is not local.
+
+ The function does the following:
+ - Try to get the IP address of the request. If it fails, assume it's not local
+ - If the request is local, return None (allow the request to continue)
+ - If strict_connection_static_file_content is set, return a response with the content
+ - Otherwise close the connection and raise an exception
+ """
+ try:
+ ip_address_ = ip_address(request.remote) # type: ignore[arg-type]
+ except ValueError:
+ _LOGGER.debug("Invalid IP address: %s", request.remote)
+ ip_address_ = None
+
+ if ip_address_ and is_local(ip_address_):
+ return None
+
+ _LOGGER.debug("Perform strict connection action for %s", ip_address_)
+ if strict_connection_static_file_content:
+ return Response(
+ text=strict_connection_static_file_content,
+ content_type="text/html",
+ status=HTTPStatus.IM_A_TEAPOT,
+ )
+
+ if transport := request.transport:
+ # it should never happen that we don't have a transport
+ transport.close()
+
+ # We need to raise an exception to stop processing the request
+ raise HTTPBadRequest
diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py
index 1254744f258..d02416c531b 100644
--- a/homeassistant/components/http/const.py
+++ b/homeassistant/components/http/const.py
@@ -1,8 +1,17 @@
"""HTTP specific constants."""
+from enum import StrEnum
from typing import Final
from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401
KEY_HASS_USER: Final = "hass_user"
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"
+
+
+class StrictConnectionMode(StrEnum):
+ """Enum for strict connection mode."""
+
+ DISABLED = "disabled"
+ STATIC_PAGE = "static_page"
+ DROP_CONNECTION = "drop_connection"
diff --git a/homeassistant/components/http/icons.json b/homeassistant/components/http/icons.json
new file mode 100644
index 00000000000..8e8b6285db7
--- /dev/null
+++ b/homeassistant/components/http/icons.json
@@ -0,0 +1,5 @@
+{
+ "services": {
+ "create_temporary_strict_connection_url": "mdi:login-variant"
+ }
+}
diff --git a/homeassistant/components/http/services.yaml b/homeassistant/components/http/services.yaml
new file mode 100644
index 00000000000..16b0debb144
--- /dev/null
+++ b/homeassistant/components/http/services.yaml
@@ -0,0 +1 @@
+create_temporary_strict_connection_url: ~
diff --git a/homeassistant/components/http/session.py b/homeassistant/components/http/session.py
new file mode 100644
index 00000000000..81668ec2ccc
--- /dev/null
+++ b/homeassistant/components/http/session.py
@@ -0,0 +1,160 @@
+"""Session http module."""
+
+from functools import lru_cache
+import logging
+
+from aiohttp.web import Request, StreamResponse
+from aiohttp_session import Session, SessionData
+from aiohttp_session.cookie_storage import EncryptedCookieStorage
+from cryptography.fernet import InvalidToken
+
+from homeassistant.auth.const import REFRESH_TOKEN_EXPIRATION
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.json import json_dumps
+from homeassistant.helpers.network import is_cloud_connection
+from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
+
+from .ban import process_wrong_login
+
+_LOGGER = logging.getLogger(__name__)
+
+COOKIE_NAME = "SC"
+PREFIXED_COOKIE_NAME = f"__Host-{COOKIE_NAME}"
+SESSION_CACHE_SIZE = 16
+
+
+def _get_cookie_name(is_secure: bool) -> str:
+ """Return the cookie name."""
+ return PREFIXED_COOKIE_NAME if is_secure else COOKIE_NAME
+
+
+class HomeAssistantCookieStorage(EncryptedCookieStorage):
+ """Home Assistant cookie storage.
+
+ Own class is required:
+ - to set the secure flag based on the connection type
+ - to use a LRU cache for session decryption
+ """
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the cookie storage."""
+ super().__init__(
+ hass.auth.session.key,
+ cookie_name=PREFIXED_COOKIE_NAME,
+ max_age=int(REFRESH_TOKEN_EXPIRATION),
+ httponly=True,
+ samesite="Lax",
+ secure=True,
+ encoder=json_dumps,
+ decoder=json_loads,
+ )
+ self._hass = hass
+
+ def _secure_connection(self, request: Request) -> bool:
+ """Return if the connection is secure (https)."""
+ return is_cloud_connection(self._hass) or request.secure
+
+ def load_cookie(self, request: Request) -> str | None:
+ """Load cookie."""
+ is_secure = self._secure_connection(request)
+ cookie_name = _get_cookie_name(is_secure)
+ return request.cookies.get(cookie_name)
+
+ @lru_cache(maxsize=SESSION_CACHE_SIZE)
+ def _decrypt_cookie(self, cookie: str) -> Session | None:
+ """Decrypt and validate cookie."""
+ try:
+ data = SessionData( # type: ignore[misc]
+ self._decoder(
+ self._fernet.decrypt(
+ cookie.encode("utf-8"), ttl=self.max_age
+ ).decode("utf-8")
+ )
+ )
+ except (InvalidToken, TypeError, ValueError, *JSON_DECODE_EXCEPTIONS):
+ _LOGGER.warning("Cannot decrypt/parse cookie value")
+ return None
+
+ session = Session(None, data=data, new=data is None, max_age=self.max_age)
+
+ # Validate session if not empty
+ if (
+ not session.empty
+ and not self._hass.auth.session.async_validate_strict_connection_session(
+ session
+ )
+ ):
+ # Invalidate session as it is not valid
+ session.invalidate()
+
+ return session
+
+ async def new_session(self) -> Session:
+ """Create a new session and mark it as changed."""
+ session = Session(None, data=None, new=True, max_age=self.max_age)
+ session.changed()
+ return session
+
+ async def load_session(self, request: Request) -> Session:
+ """Load session."""
+ # Split parent function to use lru_cache
+ if (cookie := self.load_cookie(request)) is None:
+ return await self.new_session()
+
+ if (session := self._decrypt_cookie(cookie)) is None:
+ # Decrypting/parsing failed, log wrong login and create a new session
+ await process_wrong_login(request)
+ session = await self.new_session()
+
+ return session
+
+ async def save_session(
+ self, request: Request, response: StreamResponse, session: Session
+ ) -> None:
+ """Save session."""
+
+ is_secure = self._secure_connection(request)
+ cookie_name = _get_cookie_name(is_secure)
+
+ if session.empty:
+ response.del_cookie(cookie_name)
+ else:
+ params = self.cookie_params.copy()
+ params["secure"] = is_secure
+ params["max_age"] = session.max_age
+
+ cookie_data = self._encoder(self._get_session_data(session)).encode("utf-8")
+ response.set_cookie(
+ cookie_name,
+ self._fernet.encrypt(cookie_data).decode("utf-8"),
+ **params,
+ )
+ # Add Cache-Control header to not cache the cookie as it
+ # is used for session management
+ self._add_cache_control_header(response)
+
+ @staticmethod
+ def _add_cache_control_header(response: StreamResponse) -> None:
+ """Add/set cache control header to no-cache="Set-Cookie"."""
+ # Structure of the Cache-Control header defined in
+ # https://datatracker.ietf.org/doc/html/rfc2068#section-14.9
+ if header := response.headers.get("Cache-Control"):
+ directives = []
+ for directive in header.split(","):
+ directive = directive.strip()
+ directive_lowered = directive.lower()
+ if directive_lowered.startswith("no-cache"):
+ if "set-cookie" in directive_lowered or directive.find("=") == -1:
+ # Set-Cookie is already in the no-cache directive or
+ # the whole request should not be cached -> Nothing to do
+ return
+
+ # Add Set-Cookie to the no-cache
+ # [:-1] to remove the " at the end of the directive
+ directive = f"{directive[:-1]}, Set-Cookie"
+
+ directives.append(directive)
+ header = ", ".join(directives)
+ else:
+ header = 'no-cache="Set-Cookie"'
+ response.headers["Cache-Control"] = header
diff --git a/homeassistant/components/http/strict_connection_static_page.html b/homeassistant/components/http/strict_connection_static_page.html
new file mode 100644
index 00000000000..86ea8e00e90
--- /dev/null
+++ b/homeassistant/components/http/strict_connection_static_page.html
@@ -0,0 +1,140 @@
+
+
+
+
+
+ Home Assistant - Access denied
+
+
+
+
+
+
+
You need access
+
+ This device is not known on
+ Home Assistant.
+
+
+
+ Learn how to get access
+
+
+
+
diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json
new file mode 100644
index 00000000000..7cd64f5f297
--- /dev/null
+++ b/homeassistant/components/http/strings.json
@@ -0,0 +1,16 @@
+{
+ "exceptions": {
+ "strict_connection_not_enabled_non_cloud": {
+ "message": "Strict connection is not enabled for non-cloud requests"
+ },
+ "no_external_url_available": {
+ "message": "No external URL available"
+ }
+ },
+ "services": {
+ "create_temporary_strict_connection_url": {
+ "name": "Create a temporary strict connection URL",
+ "description": "Create a temporary strict connection URL, which can be used to login on another device."
+ }
+ }
+}
diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py
index 94699ae5dd4..53d24044b53 100644
--- a/homeassistant/components/imap/coordinator.py
+++ b/homeassistant/components/imap/coordinator.py
@@ -443,23 +443,24 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
_LOGGER.debug("Connected to server %s using IMAP push", entry.data[CONF_SERVER])
super().__init__(hass, imap_client, entry, None)
self._push_wait_task: asyncio.Task[None] | None = None
+ self.number_of_messages: int | None = None
async def _async_update_data(self) -> int | None:
"""Update the number of unread emails."""
await self.async_start()
- return None
+ return self.number_of_messages
async def async_start(self) -> None:
"""Start coordinator."""
self._push_wait_task = self.hass.async_create_background_task(
- self._async_wait_push_loop(), "Wait for IMAP data push", eager_start=False
+ self._async_wait_push_loop(), "Wait for IMAP data push"
)
async def _async_wait_push_loop(self) -> None:
"""Wait for data push from server."""
while True:
try:
- number_of_messages = await self._async_fetch_number_of_messages()
+ self.number_of_messages = await self._async_fetch_number_of_messages()
except InvalidAuth as ex:
self.auth_errors += 1
await self._cleanup()
@@ -489,7 +490,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
continue
else:
self.auth_errors = 0
- self.async_set_updated_data(number_of_messages)
+ self.async_set_updated_data(self.number_of_messages)
try:
idle: asyncio.Future = await self.imap_client.idle_start()
await self.imap_client.wait_server_push()
diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py
index 6b6694c920d..94dfca77410 100644
--- a/homeassistant/components/kitchen_sink/__init__.py
+++ b/homeassistant/components/kitchen_sink/__init__.py
@@ -32,6 +32,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.IMAGE,
Platform.LAWN_MOWER,
Platform.LOCK,
+ Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
Platform.WEATHER,
@@ -70,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
return True
-def _create_issues(hass):
+def _create_issues(hass: HomeAssistant) -> None:
"""Create some issue registry issues."""
async_create_issue(
hass,
diff --git a/homeassistant/components/kitchen_sink/notify.py b/homeassistant/components/kitchen_sink/notify.py
new file mode 100644
index 00000000000..b0418411145
--- /dev/null
+++ b/homeassistant/components/kitchen_sink/notify.py
@@ -0,0 +1,54 @@
+"""Demo platform that offers a fake notify entity."""
+
+from __future__ import annotations
+
+from homeassistant.components import persistent_notification
+from homeassistant.components.notify import NotifyEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import DOMAIN
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the demo notify entity platform."""
+ async_add_entities(
+ [
+ DemoNotify(
+ unique_id="just_notify_me",
+ device_name="MyBox",
+ entity_name="Personal notifier",
+ ),
+ ]
+ )
+
+
+class DemoNotify(NotifyEntity):
+ """Representation of a demo notify entity."""
+
+ _attr_has_entity_name = True
+ _attr_should_poll = False
+
+ def __init__(
+ self,
+ unique_id: str,
+ device_name: str,
+ entity_name: str | None,
+ ) -> None:
+ """Initialize the Demo button entity."""
+ self._attr_unique_id = unique_id
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, unique_id)},
+ name=device_name,
+ )
+ self._attr_name = entity_name
+
+ async def async_send_message(self, message: str) -> None:
+ """Send out a persistent notification."""
+ persistent_notification.async_create(self.hass, message, "Demo notification")
diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py
index 332d701148e..b3b1330b3a1 100644
--- a/homeassistant/components/light/__init__.py
+++ b/homeassistant/components/light/__init__.py
@@ -517,13 +517,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
params[ATTR_COLOR_TEMP_KELVIN]
)
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
- assert (rgb_color := params.pop(ATTR_RGB_COLOR)) is not None
+ rgb_color = params.pop(ATTR_RGB_COLOR)
+ assert rgb_color is not None
if ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
- # https://github.com/python/mypy/issues/13673
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
- *rgb_color, # type: ignore[call-arg]
+ *rgb_color,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
@@ -584,9 +584,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
elif (
ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes
):
- assert (rgbww_color := params.pop(ATTR_RGBWW_COLOR)) is not None
- # https://github.com/python/mypy/issues/13673
- rgb_color = color_util.color_rgbww_to_rgb( # type: ignore[call-arg]
+ rgbww_color = params.pop(ATTR_RGBWW_COLOR)
+ assert rgbww_color is not None
+ rgb_color = color_util.color_rgbww_to_rgb(
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
if ColorMode.RGB in supported_color_modes:
diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py
index 2a82cf89fd5..08e927bb553 100644
--- a/homeassistant/components/modbus/__init__.py
+++ b/homeassistant/components/modbus/__init__.py
@@ -463,6 +463,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_reset_platform(hass: HomeAssistant, integration_name: str) -> None:
"""Release modbus resources."""
+ if DOMAIN not in hass.data:
+ _LOGGER.error("Modbus cannot reload, because it was never loaded")
+ return
_LOGGER.info("Modbus reloading")
hubs = hass.data[DOMAIN]
for name in hubs:
diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py
index 13c56a9b48e..43f4f8cfd46 100644
--- a/homeassistant/components/mqtt/discovery.py
+++ b/homeassistant/components/mqtt/discovery.py
@@ -268,7 +268,7 @@ async def async_start( # noqa: C901
availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}"
# If present, the node_id will be included in the discovered object id
- discovery_id = " ".join((node_id, object_id)) if node_id else object_id
+ discovery_id = f"{node_id} {object_id}" if node_id else object_id
discovery_hash = (component, discovery_id)
if discovery_payload:
diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py
index 6a1a791d7ac..3a0953a0158 100644
--- a/homeassistant/components/mqtt_statestream/__init__.py
+++ b/homeassistant/components/mqtt_statestream/__init__.py
@@ -57,7 +57,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def _state_publisher(evt: Event[EventStateChangedData]) -> None:
entity_id = evt.data["entity_id"]
- assert (new_state := evt.data["new_state"])
+ new_state = evt.data["new_state"]
+ assert new_state
payload = new_state.state
diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json
index f01bb1990cc..2efc0d05b34 100644
--- a/homeassistant/components/myuplink/strings.json
+++ b/homeassistant/components/myuplink/strings.json
@@ -12,12 +12,15 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
- "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"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%]",
- "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "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%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json
index 1d5edb7ca44..d6eff486b05 100644
--- a/homeassistant/components/neato/manifest.json
+++ b/homeassistant/components/neato/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/neato",
"iot_class": "cloud_polling",
"loggers": ["pybotvac"],
- "requirements": ["pybotvac==0.0.24"]
+ "requirements": ["pybotvac==0.0.25"]
}
diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py
new file mode 100644
index 00000000000..c478525753a
--- /dev/null
+++ b/homeassistant/components/netatmo/binary_sensor.py
@@ -0,0 +1,60 @@
+"""Support for Netatmo binary sensors."""
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import NETATMO_CREATE_WEATHER_SENSOR
+from .data_handler import NetatmoDevice
+from .entity import NetatmoWeatherModuleEntity
+
+BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
+ BinarySensorEntityDescription(
+ key="reachable",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+) -> None:
+ """Set up Netatmo binary sensors based on a config entry."""
+
+ @callback
+ def _create_weather_binary_sensor_entity(netatmo_device: NetatmoDevice) -> None:
+ async_add_entities(
+ NetatmoWeatherBinarySensor(netatmo_device, description)
+ for description in BINARY_SENSOR_TYPES
+ if description.key in netatmo_device.device.features
+ )
+
+ entry.async_on_unload(
+ async_dispatcher_connect(
+ hass, NETATMO_CREATE_WEATHER_SENSOR, _create_weather_binary_sensor_entity
+ )
+ )
+
+
+class NetatmoWeatherBinarySensor(NetatmoWeatherModuleEntity, BinarySensorEntity):
+ """Implementation of a Netatmo binary sensor."""
+
+ def __init__(
+ self, device: NetatmoDevice, description: BinarySensorEntityDescription
+ ) -> None:
+ """Initialize a Netatmo binary sensor."""
+ super().__init__(device)
+ self.entity_description = description
+ self._attr_unique_id = f"{self.device.entity_id}-{description.key}"
+
+ @callback
+ def async_update_callback(self) -> None:
+ """Update the entity's state."""
+ self._attr_is_on = self.device.reachable
+ self.async_write_ha_state()
diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py
index 8109b418066..74f2ebc84b2 100644
--- a/homeassistant/components/netatmo/const.py
+++ b/homeassistant/components/netatmo/const.py
@@ -9,6 +9,7 @@ MANUFACTURER = "Netatmo"
DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}"
PLATFORMS = [
+ Platform.BINARY_SENSOR,
Platform.CAMERA,
Platform.CLIMATE,
Platform.COVER,
diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py
index 5f08cb941d6..6fdebcf0c3f 100644
--- a/homeassistant/components/netatmo/entity.py
+++ b/homeassistant/components/netatmo/entity.py
@@ -3,12 +3,13 @@
from __future__ import annotations
from abc import abstractmethod
-from typing import Any
+from typing import Any, cast
from pyatmo import DeviceType, Home, Module, Room
-from pyatmo.modules.base_class import NetatmoBase
+from pyatmo.modules.base_class import NetatmoBase, Place
from pyatmo.modules.device_types import DEVICE_DESCRIPTION_MAP
+from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -16,6 +17,7 @@ from homeassistant.helpers.entity import Entity
from .const import (
CONF_URL_ENERGY,
+ CONF_URL_WEATHER,
DATA_DEVICE_IDS,
DEFAULT_ATTRIBUTION,
DOMAIN,
@@ -166,3 +168,39 @@ class NetatmoModuleEntity(NetatmoDeviceEntity):
def device_type(self) -> DeviceType:
"""Return the device type."""
return self.device.device_type
+
+
+class NetatmoWeatherModuleEntity(NetatmoModuleEntity):
+ """Netatmo weather module entity base class."""
+
+ _attr_configuration_url = CONF_URL_WEATHER
+
+ def __init__(self, device: NetatmoDevice) -> None:
+ """Set up a Netatmo weather module entity."""
+ super().__init__(device)
+ category = getattr(self.device.device_category, "name")
+ self._publishers.extend(
+ [
+ {
+ "name": category,
+ SIGNAL_NAME: category,
+ },
+ ]
+ )
+
+ if hasattr(self.device, "place"):
+ place = cast(Place, getattr(self.device, "place"))
+ if hasattr(place, "location") and place.location is not None:
+ self._attr_extra_state_attributes.update(
+ {
+ ATTR_LATITUDE: place.location.latitude,
+ ATTR_LONGITUDE: place.location.longitude,
+ }
+ )
+
+ @property
+ def device_type(self) -> DeviceType:
+ """Return the Netatmo device type."""
+ if "." not in self.device.device_type:
+ return super().device_type
+ return DeviceType(self.device.device_type.partition(".")[2])
diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py
index 7e7b6029572..fd40bbf88b6 100644
--- a/homeassistant/components/netatmo/sensor.py
+++ b/homeassistant/components/netatmo/sensor.py
@@ -8,7 +8,6 @@ import logging
from typing import Any, cast
import pyatmo
-from pyatmo import DeviceType
from pyatmo.modules import PublicWeatherArea
from homeassistant.components.sensor import (
@@ -48,7 +47,6 @@ from homeassistant.helpers.typing import StateType
from .const import (
CONF_URL_ENERGY,
CONF_URL_PUBLIC_WEATHER,
- CONF_URL_WEATHER,
CONF_WEATHER_AREAS,
DATA_HANDLER,
DOMAIN,
@@ -59,25 +57,38 @@ from .const import (
SIGNAL_NAME,
)
from .data_handler import HOME, PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom
-from .entity import NetatmoBaseEntity, NetatmoModuleEntity, NetatmoRoomEntity
+from .entity import (
+ NetatmoBaseEntity,
+ NetatmoModuleEntity,
+ NetatmoRoomEntity,
+ NetatmoWeatherModuleEntity,
+)
from .helper import NetatmoArea
_LOGGER = logging.getLogger(__name__)
+DIRECTION_OPTIONS = [
+ "n",
+ "ne",
+ "e",
+ "se",
+ "s",
+ "sw",
+ "w",
+ "nw",
+]
+
def process_health(health: StateType) -> str | None:
"""Process health index and return string for display."""
if not isinstance(health, int):
return None
- if health == 0:
- return "Healthy"
- if health == 1:
- return "Fine"
- if health == 2:
- return "Fair"
- if health == 3:
- return "Poor"
- return "Unhealthy"
+ return {
+ 0: "healthy",
+ 1: "fine",
+ 2: "fair",
+ 3: "poor",
+ }.get(health, "unhealthy")
def process_rf(strength: StateType) -> str | None:
@@ -196,6 +207,9 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription(
key="windangle",
netatmo_name="wind_direction",
+ device_class=SensorDeviceClass.ENUM,
+ options=DIRECTION_OPTIONS,
+ value_fn=lambda x: x.lower() if isinstance(x, str) else None,
),
NetatmoSensorEntityDescription(
key="windangle_value",
@@ -215,6 +229,9 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
key="gustangle",
netatmo_name="gust_direction",
entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.ENUM,
+ options=DIRECTION_OPTIONS,
+ value_fn=lambda x: x.lower() if isinstance(x, str) else None,
),
NetatmoSensorEntityDescription(
key="gustangle_value",
@@ -254,6 +271,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription(
key="health_idx",
netatmo_name="health_idx",
+ device_class=SensorDeviceClass.ENUM,
+ options=["healthy", "fine", "fair", "poor", "unhealthy"],
value_fn=process_health,
),
NetatmoSensorEntityDescription(
@@ -491,11 +510,10 @@ async def async_setup_entry(
await add_public_entities(False)
-class NetatmoWeatherSensor(NetatmoModuleEntity, SensorEntity):
+class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, SensorEntity):
"""Implementation of a Netatmo weather/home coach sensor."""
entity_description: NetatmoSensorEntityDescription
- _attr_configuration_url = CONF_URL_WEATHER
def __init__(
self,
@@ -506,34 +524,8 @@ class NetatmoWeatherSensor(NetatmoModuleEntity, SensorEntity):
super().__init__(netatmo_device)
self.entity_description = description
self._attr_translation_key = description.netatmo_name
- category = getattr(self.device.device_category, "name")
- self._publishers.extend(
- [
- {
- "name": category,
- SIGNAL_NAME: category,
- },
- ]
- )
self._attr_unique_id = f"{self.device.entity_id}-{description.key}"
- if hasattr(self.device, "place"):
- place = cast(pyatmo.modules.base_class.Place, getattr(self.device, "place"))
- if hasattr(place, "location") and place.location is not None:
- self._attr_extra_state_attributes.update(
- {
- ATTR_LATITUDE: place.location.latitude,
- ATTR_LONGITUDE: place.location.longitude,
- }
- )
-
- @property
- def device_type(self) -> DeviceType:
- """Return the Netatmo device type."""
- if "." not in self.device.device_type:
- return super().device_type
- return DeviceType(self.device.device_type.partition(".")[2])
-
@property
def available(self) -> bool:
"""Return True if entity is available."""
diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json
index f6aba92d005..3c360634147 100644
--- a/homeassistant/components/netatmo/strings.json
+++ b/homeassistant/components/netatmo/strings.json
@@ -185,13 +185,33 @@
"name": "Precipitation today"
},
"wind_direction": {
- "name": "Wind direction"
+ "name": "Wind direction",
+ "state": {
+ "n": "North",
+ "ne": "North-east",
+ "e": "East",
+ "se": "South-east",
+ "s": "South",
+ "sw": "South-west",
+ "w": "West",
+ "nw": "North-west"
+ }
},
"wind_angle": {
"name": "Wind angle"
},
"gust_direction": {
- "name": "Gust direction"
+ "name": "Gust direction",
+ "state": {
+ "n": "[%key:component::netatmo::entity::sensor::wind_direction::state::n%]",
+ "ne": "[%key:component::netatmo::entity::sensor::wind_direction::state::ne%]",
+ "e": "[%key:component::netatmo::entity::sensor::wind_direction::state::e%]",
+ "se": "[%key:component::netatmo::entity::sensor::wind_direction::state::se%]",
+ "s": "[%key:component::netatmo::entity::sensor::wind_direction::state::s%]",
+ "sw": "[%key:component::netatmo::entity::sensor::wind_direction::state::sw%]",
+ "w": "[%key:component::netatmo::entity::sensor::wind_direction::state::w%]",
+ "nw": "[%key:component::netatmo::entity::sensor::wind_direction::state::nw%]"
+ }
},
"gust_angle": {
"name": "Gust angle"
@@ -209,7 +229,14 @@
"name": "Wi-Fi"
},
"health_idx": {
- "name": "Health index"
+ "name": "Health index",
+ "state": {
+ "healthy": "Healthy",
+ "fine": "Fine",
+ "fair": "Fair",
+ "poor": "Poor",
+ "unhealthy": "Unhealthy"
+ }
}
}
}
diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py
index 5f89d0d79db..8cd0d177835 100644
--- a/homeassistant/components/nextbus/sensor.py
+++ b/homeassistant/components/nextbus/sensor.py
@@ -82,11 +82,13 @@ class NextBusDepartureSensor(
def _log_debug(self, message, *args):
"""Log debug message with prefix."""
- _LOGGER.debug(":".join((self.agency, self.route, self.stop, message)), *args)
+ msg = f"{self.agency}:{self.route}:{self.stop}:{message}"
+ _LOGGER.debug(msg, *args)
def _log_err(self, message, *args):
"""Log error message with prefix."""
- _LOGGER.error(":".join((self.agency, self.route, self.stop, message)), *args)
+ msg = f"{self.agency}:{self.route}:{self.stop}:{message}"
+ _LOGGER.error(msg, *args)
async def async_added_to_hass(self) -> None:
"""Read data from coordinator after adding to hass."""
diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py
index 6128272fbbb..a89c50a2210 100644
--- a/homeassistant/components/nmap_tracker/config_flow.py
+++ b/homeassistant/components/nmap_tracker/config_flow.py
@@ -62,7 +62,7 @@ def _normalize_ips_and_network(hosts_str: str) -> list[str] | None:
start, end = host.split("-", 1)
if "." not in end:
ip_1, ip_2, ip_3, _ = start.split(".", 3)
- end = ".".join([ip_1, ip_2, ip_3, end])
+ end = f"{ip_1}.{ip_2}.{ip_3}.{end}"
summarize_address_range(ip_address(start), ip_address(end))
except ValueError:
pass
diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py
index e7390a49676..81b7d300acc 100644
--- a/homeassistant/components/notify/__init__.py
+++ b/homeassistant/components/notify/__init__.py
@@ -2,24 +2,36 @@
from __future__ import annotations
+from datetime import timedelta
+from functools import cached_property, partial
+import logging
+from typing import Any, final, override
+
import voluptuous as vol
import homeassistant.components.persistent_notification as pn
-from homeassistant.const import CONF_NAME, CONF_PLATFORM
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, ServiceCall
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import dt as dt_util
from .const import ( # noqa: F401
ATTR_DATA,
ATTR_MESSAGE,
+ ATTR_RECIPIENTS,
ATTR_TARGET,
ATTR_TITLE,
DOMAIN,
NOTIFY_SERVICE_SCHEMA,
SERVICE_NOTIFY,
SERVICE_PERSISTENT_NOTIFICATION,
+ SERVICE_SEND_MESSAGE,
)
from .legacy import ( # noqa: F401
BaseNotificationService,
@@ -29,9 +41,17 @@ from .legacy import ( # noqa: F401
check_templates_warn,
)
+# mypy: disallow-any-generics
+
# Platform specific data
ATTR_TITLE_DEFAULT = "Home Assistant"
+ENTITY_ID_FORMAT = DOMAIN + ".{}"
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
+
+_LOGGER = logging.getLogger(__name__)
+
PLATFORM_SCHEMA = vol.Schema(
{vol.Required(CONF_PLATFORM): cv.string, vol.Optional(CONF_NAME): cv.string},
extra=vol.ALLOW_EXTRA,
@@ -50,6 +70,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# legacy platforms to finish setting up.
hass.async_create_task(setup, eager_start=True)
+ component = hass.data[DOMAIN] = EntityComponent[NotifyEntity](_LOGGER, DOMAIN, hass)
+ component.async_register_entity_service(
+ SERVICE_SEND_MESSAGE,
+ {vol.Required(ATTR_MESSAGE): cv.string},
+ "_async_send_message",
+ )
+
async def persistent_notification(service: ServiceCall) -> None:
"""Send notification via the built-in persistent_notify integration."""
message: Template = service.data[ATTR_MESSAGE]
@@ -79,3 +106,66 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
return True
+
+
+class NotifyEntityDescription(EntityDescription, frozen_or_thawed=True):
+ """A class that describes button entities."""
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up a config entry."""
+ component: EntityComponent[NotifyEntity] = hass.data[DOMAIN]
+ return await component.async_setup_entry(entry)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+ component: EntityComponent[NotifyEntity] = hass.data[DOMAIN]
+ return await component.async_unload_entry(entry)
+
+
+class NotifyEntity(RestoreEntity):
+ """Representation of a notify entity."""
+
+ entity_description: NotifyEntityDescription
+ _attr_should_poll = False
+ _attr_device_class: None
+ _attr_state: None = None
+ __last_notified_isoformat: str | None = None
+
+ @cached_property
+ @final
+ @override
+ def state(self) -> str | None:
+ """Return the entity state."""
+ return self.__last_notified_isoformat
+
+ def __set_state(self, state: str | None) -> None:
+ """Invalidate the cache of the cached property."""
+ self.__dict__.pop("state", None)
+ self.__last_notified_isoformat = state
+
+ async def async_internal_added_to_hass(self) -> None:
+ """Call when the notify entity is added to hass."""
+ await super().async_internal_added_to_hass()
+ state = await self.async_get_last_state()
+ if state is not None and state.state not in (STATE_UNAVAILABLE, None):
+ self.__set_state(state.state)
+
+ @final
+ async def _async_send_message(self, **kwargs: Any) -> None:
+ """Send a notification message (from e.g., service call).
+
+ Should not be overridden, handle setting last notification timestamp.
+ """
+ self.__set_state(dt_util.utcnow().isoformat())
+ self.async_write_ha_state()
+ await self.async_send_message(**kwargs)
+
+ def send_message(self, message: str) -> None:
+ """Send a message."""
+ raise NotImplementedError
+
+ async def async_send_message(self, message: str) -> None:
+ """Send a message."""
+ await self.hass.async_add_executor_job(partial(self.send_message, message))
diff --git a/homeassistant/components/notify/const.py b/homeassistant/components/notify/const.py
index b653b5d1cbf..6cd957e3afe 100644
--- a/homeassistant/components/notify/const.py
+++ b/homeassistant/components/notify/const.py
@@ -11,9 +11,12 @@ ATTR_DATA = "data"
# Text to notify user of
ATTR_MESSAGE = "message"
-# Target of the notification (user, device, etc)
+# Target of the (legacy) notification (user, device, etc)
ATTR_TARGET = "target"
+# Recipients for a notification
+ATTR_RECIPIENTS = "recipients"
+
# Title of notification
ATTR_TITLE = "title"
@@ -22,6 +25,7 @@ DOMAIN = "notify"
LOGGER = logging.getLogger(__package__)
SERVICE_NOTIFY = "notify"
+SERVICE_SEND_MESSAGE = "send_message"
SERVICE_PERSISTENT_NOTIFICATION = "persistent_notification"
NOTIFY_SERVICE_SCHEMA = vol.Schema(
diff --git a/homeassistant/components/notify/icons.json b/homeassistant/components/notify/icons.json
index 88577bc2356..ace8ee0c96b 100644
--- a/homeassistant/components/notify/icons.json
+++ b/homeassistant/components/notify/icons.json
@@ -1,6 +1,12 @@
{
+ "entity_component": {
+ "_": {
+ "default": "mdi:message"
+ }
+ },
"services": {
"notify": "mdi:bell-ring",
- "persistent_notification": "mdi:bell-badge"
+ "persistent_notification": "mdi:bell-badge",
+ "send_message": "mdi:message-arrow-right"
}
}
diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml
index 8d053e3af58..ae2a0254761 100644
--- a/homeassistant/components/notify/services.yaml
+++ b/homeassistant/components/notify/services.yaml
@@ -20,6 +20,16 @@ notify:
selector:
object:
+send_message:
+ target:
+ entity:
+ domain: notify
+ fields:
+ message:
+ required: true
+ selector:
+ text:
+
persistent_notification:
fields:
message:
diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json
index cff7b265c37..b0dca501509 100644
--- a/homeassistant/components/notify/strings.json
+++ b/homeassistant/components/notify/strings.json
@@ -1,5 +1,10 @@
{
"title": "Notifications",
+ "entity_component": {
+ "_": {
+ "name": "[%key:component::notify::title%]"
+ }
+ },
"services": {
"notify": {
"name": "Send a notification",
@@ -23,6 +28,16 @@
}
}
},
+ "send_message": {
+ "name": "Send a notification message",
+ "description": "Sends a notification message.",
+ "fields": {
+ "message": {
+ "name": "Message",
+ "description": "Your notification message."
+ }
+ }
+ },
"persistent_notification": {
"name": "Send a persistent notification",
"description": "Sends a notification that is visible in the **Notifications** panel.",
diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py
index d4cce99e1cc..94a56bb1922 100644
--- a/homeassistant/components/opower/coordinator.py
+++ b/homeassistant/components/opower/coordinator.py
@@ -159,13 +159,9 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
)
)
- name_prefix = " ".join(
- (
- "Opower",
- self.api.utility.subdomain(),
- account.meter_type.name.lower(),
- account.utility_account_id,
- )
+ name_prefix = (
+ f"Opower {self.api.utility.subdomain()} "
+ f"{account.meter_type.name.lower()} {account.utility_account_id}"
)
cost_metadata = StatisticMetaData(
has_mean=False,
diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py
index a929f5b875b..7f43553aa41 100644
--- a/homeassistant/components/rainbird/switch.py
+++ b/homeassistant/components/rainbird/switch.py
@@ -123,7 +123,8 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity)
# The device reflects the old state for a few moments. Update the
# state manually and trigger a refresh after a short debounced delay.
- self.coordinator.data.active_zones.remove(self._zone)
+ if self.is_on:
+ self.coordinator.data.active_zones.remove(self._zone)
self.async_write_ha_state()
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py
index ffa99704526..36c66550ddc 100644
--- a/homeassistant/components/ring/__init__.py
+++ b/homeassistant/components/ring/__init__.py
@@ -2,10 +2,12 @@
from __future__ import annotations
+from dataclasses import dataclass
from functools import partial
import logging
+from typing import Any, cast
-from ring_doorbell import Auth, Ring
+from ring_doorbell import Auth, Ring, RingDevices
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__
@@ -13,23 +15,26 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from .const import (
- DOMAIN,
- PLATFORMS,
- RING_API,
- RING_DEVICES,
- RING_DEVICES_COORDINATOR,
- RING_NOTIFICATIONS_COORDINATOR,
-)
+from .const import DOMAIN, PLATFORMS
from .coordinator import RingDataCoordinator, RingNotificationsCoordinator
_LOGGER = logging.getLogger(__name__)
+@dataclass
+class RingData:
+ """Class to support type hinting of ring data collection."""
+
+ api: Ring
+ devices: RingDevices
+ devices_coordinator: RingDataCoordinator
+ notifications_coordinator: RingNotificationsCoordinator
+
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
- def token_updater(token):
+ def token_updater(token: dict[str, Any]) -> None:
"""Handle from sync context when token is updated."""
hass.loop.call_soon_threadsafe(
partial(
@@ -51,12 +56,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await devices_coordinator.async_config_entry_first_refresh()
await notifications_coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
- RING_API: ring,
- RING_DEVICES: ring.devices(),
- RING_DEVICES_COORDINATOR: devices_coordinator,
- RING_NOTIFICATIONS_COORDINATOR: notifications_coordinator,
- }
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RingData(
+ api=ring,
+ devices=ring.devices(),
+ devices_coordinator=devices_coordinator,
+ notifications_coordinator=notifications_coordinator,
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -83,8 +88,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
for info in hass.data[DOMAIN].values():
- await info[RING_DEVICES_COORDINATOR].async_refresh()
- await info[RING_NOTIFICATIONS_COORDINATOR].async_refresh()
+ ring_data = cast(RingData, info)
+ await ring_data.devices_coordinator.async_refresh()
+ await ring_data.notifications_coordinator.async_refresh()
# register service
hass.services.async_register(DOMAIN, "update", async_refresh_all)
@@ -121,8 +127,9 @@ async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None:
@callback
def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
# Old format for camera and light was int
- if isinstance(entity_entry.unique_id, int):
- new_unique_id = str(entity_entry.unique_id)
+ unique_id = cast(str | int, entity_entry.unique_id)
+ if isinstance(unique_id, int):
+ new_unique_id = str(unique_id)
if existing_entity_id := entity_registry.async_get_entity_id(
entity_entry.domain, entity_entry.platform, new_unique_id
):
diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py
index 19daebf9ce1..2db04cfd461 100644
--- a/homeassistant/components/ring/binary_sensor.py
+++ b/homeassistant/components/ring/binary_sensor.py
@@ -2,10 +2,13 @@
from __future__ import annotations
+from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any
+from ring_doorbell import Ring, RingEvent, RingGeneric
+
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -15,29 +18,32 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATOR
+from . import RingData
+from .const import DOMAIN
from .coordinator import RingNotificationsCoordinator
-from .entity import RingEntity
+from .entity import RingBaseEntity
@dataclass(frozen=True, kw_only=True)
class RingBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Ring binary sensor entity."""
- category: list[str]
+ exists_fn: Callable[[RingGeneric], bool]
BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = (
RingBinarySensorEntityDescription(
key="ding",
translation_key="ding",
- category=["doorbots", "authorized_doorbots", "other"],
device_class=BinarySensorDeviceClass.OCCUPANCY,
+ exists_fn=lambda device: device.family
+ in {"doorbots", "authorized_doorbots", "other"},
),
RingBinarySensorEntityDescription(
key="motion",
- category=["doorbots", "authorized_doorbots", "stickup_cams"],
device_class=BinarySensorDeviceClass.MOTION,
+ exists_fn=lambda device: device.family
+ in {"doorbots", "authorized_doorbots", "stickup_cams"},
),
)
@@ -48,34 +54,36 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Ring binary sensors from a config entry."""
- ring = hass.data[DOMAIN][config_entry.entry_id][RING_API]
- devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
- notifications_coordinator: RingNotificationsCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ][RING_NOTIFICATIONS_COORDINATOR]
+ ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
entities = [
- RingBinarySensor(ring, device, notifications_coordinator, description)
- for device_type in ("doorbots", "authorized_doorbots", "stickup_cams", "other")
+ RingBinarySensor(
+ ring_data.api,
+ device,
+ ring_data.notifications_coordinator,
+ description,
+ )
for description in BINARY_SENSOR_TYPES
- if device_type in description.category
- for device in devices[device_type]
+ for device in ring_data.devices.all_devices
+ if description.exists_fn(device)
]
async_add_entities(entities)
-class RingBinarySensor(RingEntity, BinarySensorEntity):
+class RingBinarySensor(
+ RingBaseEntity[RingNotificationsCoordinator], BinarySensorEntity
+):
"""A binary sensor implementation for Ring device."""
- _active_alert: dict[str, Any] | None = None
+ _active_alert: RingEvent | None = None
entity_description: RingBinarySensorEntityDescription
def __init__(
self,
- ring,
- device,
- coordinator,
+ ring: Ring,
+ device: RingGeneric,
+ coordinator: RingNotificationsCoordinator,
description: RingBinarySensorEntityDescription,
) -> None:
"""Initialize a sensor for Ring device."""
@@ -89,13 +97,13 @@ class RingBinarySensor(RingEntity, BinarySensorEntity):
self._update_alert()
@callback
- def _handle_coordinator_update(self, _=None):
+ def _handle_coordinator_update(self, _: Any = None) -> None:
"""Call update method."""
self._update_alert()
super()._handle_coordinator_update()
@callback
- def _update_alert(self):
+ def _update_alert(self) -> None:
"""Update active alert."""
self._active_alert = next(
(
@@ -108,21 +116,23 @@ class RingBinarySensor(RingEntity, BinarySensorEntity):
)
@property
- def is_on(self):
+ def is_on(self) -> bool:
"""Return True if the binary sensor is on."""
return self._active_alert is not None
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the state attributes."""
attrs = super().extra_state_attributes
if self._active_alert is None:
return attrs
+ assert isinstance(attrs, dict)
attrs["state"] = self._active_alert["state"]
- attrs["expires_at"] = datetime.fromtimestamp(
- self._active_alert.get("now") + self._active_alert.get("expires_in")
- ).isoformat()
+ now = self._active_alert.get("now")
+ expires_in = self._active_alert.get("expires_in")
+ assert now and expires_in
+ attrs["expires_at"] = datetime.fromtimestamp(now + expires_in).isoformat()
return attrs
diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py
index d739dc29841..15d56a8b7cf 100644
--- a/homeassistant/components/ring/button.py
+++ b/homeassistant/components/ring/button.py
@@ -2,12 +2,15 @@
from __future__ import annotations
+from ring_doorbell import RingOther
+
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
+from . import RingData
+from .const import DOMAIN
from .coordinator import RingDataCoordinator
from .entity import RingEntity, exception_wrap
@@ -22,25 +25,23 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the buttons for the Ring devices."""
- devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
- devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
- RING_DEVICES_COORDINATOR
- ]
+ ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
+ devices_coordinator = ring_data.devices_coordinator
async_add_entities(
RingDoorButton(device, devices_coordinator, BUTTON_DESCRIPTION)
- for device in devices["other"]
+ for device in ring_data.devices.other
if device.has_capability("open")
)
-class RingDoorButton(RingEntity, ButtonEntity):
+class RingDoorButton(RingEntity[RingOther], ButtonEntity):
"""Creates a button to open the ring intercom door."""
def __init__(
self,
- device,
- coordinator,
+ device: RingOther,
+ coordinator: RingDataCoordinator,
description: ButtonEntityDescription,
) -> None:
"""Initialize the button."""
@@ -52,6 +53,6 @@ class RingDoorButton(RingEntity, ButtonEntity):
self._attr_unique_id = f"{device.id}-{description.key}"
@exception_wrap
- def press(self):
+ def press(self) -> None:
"""Open the door."""
self._device.open_door()
diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py
index b9d73afe6de..a5144777eaa 100644
--- a/homeassistant/components/ring/camera.py
+++ b/homeassistant/components/ring/camera.py
@@ -3,11 +3,12 @@
from __future__ import annotations
from datetime import timedelta
-from itertools import chain
import logging
-from typing import Optional
+from typing import Any
+from aiohttp import web
from haffmpeg.camera import CameraMjpeg
+from ring_doorbell import RingDoorBell
from homeassistant.components import ffmpeg
from homeassistant.components.camera import Camera
@@ -17,7 +18,8 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
-from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
+from . import RingData
+from .const import DOMAIN
from .coordinator import RingDataCoordinator
from .entity import RingEntity, exception_wrap
@@ -33,50 +35,50 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a Ring Door Bell and StickUp Camera."""
- devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
- devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
- RING_DEVICES_COORDINATOR
- ]
+ ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
+ devices_coordinator = ring_data.devices_coordinator
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)
- cams = []
- for camera in chain(
- devices["doorbots"], devices["authorized_doorbots"], devices["stickup_cams"]
- ):
- if not camera.has_subscription:
- continue
-
- cams.append(RingCam(camera, devices_coordinator, ffmpeg_manager))
+ cams = [
+ RingCam(camera, devices_coordinator, ffmpeg_manager)
+ for camera in ring_data.devices.video_devices
+ if camera.has_subscription
+ ]
async_add_entities(cams)
-class RingCam(RingEntity, Camera):
+class RingCam(RingEntity[RingDoorBell], Camera):
"""An implementation of a Ring Door Bell camera."""
_attr_name = None
- def __init__(self, device, coordinator, ffmpeg_manager):
+ def __init__(
+ self,
+ device: RingDoorBell,
+ coordinator: RingDataCoordinator,
+ ffmpeg_manager: ffmpeg.FFmpegManager,
+ ) -> None:
"""Initialize a Ring Door Bell camera."""
super().__init__(device, coordinator)
Camera.__init__(self)
-
self._ffmpeg_manager = ffmpeg_manager
- self._last_event = None
- self._last_video_id = None
- self._video_url = None
- self._image = None
+ self._last_event: dict[str, Any] | None = None
+ self._last_video_id: int | None = None
+ self._video_url: str | None = None
+ self._image: bytes | None = None
self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL
self._attr_unique_id = str(device.id)
if device.has_capability(MOTION_DETECTION_CAPABILITY):
self._attr_motion_detection_enabled = device.motion_detection
@callback
- def _handle_coordinator_update(self):
+ def _handle_coordinator_update(self) -> None:
"""Call update method."""
- history_data: Optional[list]
- if not (history_data := self._get_coordinator_history()):
- return
+ self._device = self._get_coordinator_data().get_video_device(
+ self._device.device_api_id
+ )
+ history_data = self._device.last_history
if history_data:
self._last_event = history_data[0]
self.async_schedule_update_ha_state(True)
@@ -89,7 +91,7 @@ class RingCam(RingEntity, Camera):
self.async_write_ha_state()
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
return {
"video_url": self._video_url,
@@ -100,7 +102,7 @@ class RingCam(RingEntity, Camera):
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image response from the camera."""
- if self._image is None and self._video_url:
+ if self._image is None and self._video_url is not None:
image = await ffmpeg.async_get_image(
self.hass,
self._video_url,
@@ -113,10 +115,12 @@ class RingCam(RingEntity, Camera):
return self._image
- async def handle_async_mjpeg_stream(self, request):
+ async def handle_async_mjpeg_stream(
+ self, request: web.Request
+ ) -> web.StreamResponse | None:
"""Generate an HTTP MJPEG stream from the camera."""
if self._video_url is None:
- return
+ return None
stream = CameraMjpeg(self._ffmpeg_manager.binary)
await stream.open_camera(self._video_url)
@@ -132,7 +136,7 @@ class RingCam(RingEntity, Camera):
finally:
await stream.close()
- async def async_update(self):
+ async def async_update(self) -> None:
"""Update camera entity and refresh attributes."""
if (
self._device.has_capability(MOTION_DETECTION_CAPABILITY)
@@ -160,11 +164,15 @@ class RingCam(RingEntity, Camera):
self._expires_at = FORCE_REFRESH_INTERVAL + utcnow
@exception_wrap
- def _get_video(self):
- return self._device.recording_url(self._last_event["id"])
+ def _get_video(self) -> str | None:
+ if self._last_event is None:
+ return None
+ event_id = self._last_event.get("id")
+ assert event_id and isinstance(event_id, int)
+ return self._device.recording_url(event_id)
@exception_wrap
- def _set_motion_detection_enabled(self, new_state):
+ def _set_motion_detection_enabled(self, new_state: bool) -> None:
if not self._device.has_capability(MOTION_DETECTION_CAPABILITY):
_LOGGER.error(
"Entity %s does not have motion detection capability", self.entity_id
diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py
index 6d4f28eb311..4762017c5bc 100644
--- a/homeassistant/components/ring/config_flow.py
+++ b/homeassistant/components/ring/config_flow.py
@@ -28,7 +28,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
-async def validate_input(hass: HomeAssistant, data):
+async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
auth = Auth(f"{APPLICATION_NAME}/{ha_version}")
@@ -56,9 +56,11 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
user_pass: dict[str, Any] = {}
reauth_entry: ConfigEntry | None = None
- async def async_step_user(self, user_input=None):
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
"""Handle the initial step."""
- errors = {}
+ errors: dict[str, str] = {}
if user_input is not None:
try:
token = await validate_input(self.hass, user_input)
@@ -82,7 +84,9 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
- async def async_step_2fa(self, user_input=None):
+ async def async_step_2fa(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
"""Handle 2fa step."""
if user_input:
if self.reauth_entry:
@@ -110,7 +114,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
- errors = {}
+ errors: dict[str, str] = {}
assert self.reauth_entry is not None
if user_input:
diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py
index 23f378a38be..70813a78c76 100644
--- a/homeassistant/components/ring/const.py
+++ b/homeassistant/components/ring/const.py
@@ -28,10 +28,4 @@ PLATFORMS = [
SCAN_INTERVAL = timedelta(minutes=1)
NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5)
-RING_API = "api"
-RING_DEVICES = "devices"
-
-RING_DEVICES_COORDINATOR = "device_data"
-RING_NOTIFICATIONS_COORDINATOR = "dings_data"
-
CONF_2FA = "2fa"
diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py
index fdb6fc1f296..a10f9317bab 100644
--- a/homeassistant/components/ring/coordinator.py
+++ b/homeassistant/components/ring/coordinator.py
@@ -2,11 +2,10 @@
from asyncio import TaskGroup
from collections.abc import Callable
-from dataclasses import dataclass
import logging
-from typing import Any, Optional
+from typing import TypeVar, TypeVarTuple
-from ring_doorbell import AuthenticationError, Ring, RingError, RingGeneric, RingTimeout
+from ring_doorbell import AuthenticationError, Ring, RingDevices, RingError, RingTimeout
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -16,10 +15,13 @@ from .const import NOTIFICATIONS_SCAN_INTERVAL, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
+_R = TypeVar("_R")
+_Ts = TypeVarTuple("_Ts")
+
async def _call_api(
- hass: HomeAssistant, target: Callable[..., Any], *args, msg_suffix: str = ""
-):
+ hass: HomeAssistant, target: Callable[[*_Ts], _R], *args: *_Ts, msg_suffix: str = ""
+) -> _R:
try:
return await hass.async_add_executor_job(target, *args)
except AuthenticationError as err:
@@ -34,15 +36,7 @@ async def _call_api(
raise UpdateFailed(f"Error communicating with API{msg_suffix}: {err}") from err
-@dataclass
-class RingDeviceData:
- """RingDeviceData."""
-
- device: RingGeneric
- history: Optional[list] = None
-
-
-class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]):
+class RingDataCoordinator(DataUpdateCoordinator[RingDevices]):
"""Base class for device coordinators."""
def __init__(
@@ -60,45 +54,39 @@ class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]):
self.ring_api: Ring = ring_api
self.first_call: bool = True
- async def _async_update_data(self):
+ async def _async_update_data(self) -> RingDevices:
"""Fetch data from API endpoint."""
update_method: str = "update_data" if self.first_call else "update_devices"
await _call_api(self.hass, getattr(self.ring_api, update_method))
self.first_call = False
- data: dict[str, RingDeviceData] = {}
- devices: dict[str : list[RingGeneric]] = self.ring_api.devices()
+ devices: RingDevices = self.ring_api.devices()
subscribed_device_ids = set(self.async_contexts())
- for device_type in devices:
- for device in devices[device_type]:
- # Don't update all devices in the ring api, only those that set
- # their device id as context when they subscribed.
- if device.id in subscribed_device_ids:
- data[device.id] = RingDeviceData(device=device)
- try:
- history_task = None
- async with TaskGroup() as tg:
- if device.has_capability("history"):
- history_task = tg.create_task(
- _call_api(
- self.hass,
- lambda device: device.history(limit=10),
- device,
- msg_suffix=f" for device {device.name}", # device_id is the mac
- )
- )
+ for device in devices.all_devices:
+ # Don't update all devices in the ring api, only those that set
+ # their device id as context when they subscribed.
+ if device.id in subscribed_device_ids:
+ try:
+ async with TaskGroup() as tg:
+ if device.has_capability("history"):
tg.create_task(
_call_api(
self.hass,
- device.update_health_data,
- msg_suffix=f" for device {device.name}",
+ lambda device: device.history(limit=10),
+ device,
+ msg_suffix=f" for device {device.name}", # device_id is the mac
)
)
- if history_task:
- data[device.id].history = history_task.result()
- except ExceptionGroup as eg:
- raise eg.exceptions[0] # noqa: B904
+ tg.create_task(
+ _call_api(
+ self.hass,
+ device.update_health_data,
+ msg_suffix=f" for device {device.name}",
+ )
+ )
+ except ExceptionGroup as eg:
+ raise eg.exceptions[0] # noqa: B904
- return data
+ return devices
class RingNotificationsCoordinator(DataUpdateCoordinator[None]):
@@ -114,6 +102,6 @@ class RingNotificationsCoordinator(DataUpdateCoordinator[None]):
)
self.ring_api: Ring = ring_api
- async def _async_update_data(self):
+ async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
await _call_api(self.hass, self.ring_api.update_dings)
diff --git a/homeassistant/components/ring/diagnostics.py b/homeassistant/components/ring/diagnostics.py
index 5295629979a..2e7604d9f50 100644
--- a/homeassistant/components/ring/diagnostics.py
+++ b/homeassistant/components/ring/diagnostics.py
@@ -4,12 +4,11 @@ from __future__ import annotations
from typing import Any
-from ring_doorbell import Ring
-
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from . import RingData
from .const import DOMAIN
TO_REDACT = {
@@ -33,11 +32,12 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- ring: Ring = hass.data[DOMAIN][entry.entry_id]["api"]
+ ring_data: RingData = hass.data[DOMAIN][entry.entry_id]
+ devices_data = ring_data.api.devices_data
devices_raw = [
- ring.devices_data[device_type][device_id]
- for device_type in ring.devices_data
- for device_id in ring.devices_data[device_type]
+ devices_data[device_type][device_id]
+ for device_type in devices_data
+ for device_id in devices_data[device_type]
]
return async_redact_data(
{"device_data": devices_raw},
diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py
index fb617ecd7d1..65ccbb8ece4 100644
--- a/homeassistant/components/ring/entity.py
+++ b/homeassistant/components/ring/entity.py
@@ -1,9 +1,16 @@
"""Base class for Ring entity."""
from collections.abc import Callable
-from typing import Any, Concatenate, ParamSpec, TypeVar
+from typing import Any, Concatenate, Generic, ParamSpec, cast
-from ring_doorbell import AuthenticationError, RingError, RingGeneric, RingTimeout
+from ring_doorbell import (
+ AuthenticationError,
+ RingDevices,
+ RingError,
+ RingGeneric,
+ RingTimeout,
+)
+from typing_extensions import TypeVar
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
@@ -11,26 +18,25 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN
-from .coordinator import (
- RingDataCoordinator,
- RingDeviceData,
- RingNotificationsCoordinator,
-)
+from .coordinator import RingDataCoordinator, RingNotificationsCoordinator
+
+RingDeviceT = TypeVar("RingDeviceT", bound=RingGeneric, default=RingGeneric)
_RingCoordinatorT = TypeVar(
"_RingCoordinatorT",
bound=(RingDataCoordinator | RingNotificationsCoordinator),
)
-_T = TypeVar("_T", bound="RingEntity")
+_RingBaseEntityT = TypeVar("_RingBaseEntityT", bound="RingBaseEntity[Any, Any]")
+_R = TypeVar("_R")
_P = ParamSpec("_P")
def exception_wrap(
- func: Callable[Concatenate[_T, _P], Any],
-) -> Callable[Concatenate[_T, _P], Any]:
+ func: Callable[Concatenate[_RingBaseEntityT, _P], _R],
+) -> Callable[Concatenate[_RingBaseEntityT, _P], _R]:
"""Define a wrapper to catch exceptions and raise HomeAssistant errors."""
- def _wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
+ def _wrap(self: _RingBaseEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
return func(self, *args, **kwargs)
except AuthenticationError as err:
@@ -50,7 +56,9 @@ def exception_wrap(
return _wrap
-class RingEntity(CoordinatorEntity[_RingCoordinatorT]):
+class RingBaseEntity(
+ CoordinatorEntity[_RingCoordinatorT], Generic[_RingCoordinatorT, RingDeviceT]
+):
"""Base implementation for Ring device."""
_attr_attribution = ATTRIBUTION
@@ -59,7 +67,7 @@ class RingEntity(CoordinatorEntity[_RingCoordinatorT]):
def __init__(
self,
- device: RingGeneric,
+ device: RingDeviceT,
coordinator: _RingCoordinatorT,
) -> None:
"""Initialize a sensor for Ring device."""
@@ -73,29 +81,17 @@ class RingEntity(CoordinatorEntity[_RingCoordinatorT]):
name=device.name,
)
- def _get_coordinator_device_data(self) -> RingDeviceData | None:
- if (data := self.coordinator.data) and (
- device_data := data.get(self._device.id)
- ):
- return device_data
- return None
- def _get_coordinator_device(self) -> RingGeneric | None:
- if (device_data := self._get_coordinator_device_data()) and (
- device := device_data.device
- ):
- return device
- return None
+class RingEntity(RingBaseEntity[RingDataCoordinator, RingDeviceT]):
+ """Implementation for Ring devices."""
- def _get_coordinator_history(self) -> list | None:
- if (device_data := self._get_coordinator_device_data()) and (
- history := device_data.history
- ):
- return history
- return None
+ def _get_coordinator_data(self) -> RingDevices:
+ return self.coordinator.data
@callback
def _handle_coordinator_update(self) -> None:
- if device := self._get_coordinator_device():
- self._device = device
+ self._device = cast(
+ RingDeviceT,
+ self._get_coordinator_data().get_device(self._device.device_api_id),
+ )
super()._handle_coordinator_update()
diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py
index b9e1c8c38b4..5747c9e77f7 100644
--- a/homeassistant/components/ring/light.py
+++ b/homeassistant/components/ring/light.py
@@ -1,6 +1,7 @@
"""Component providing HA switch support for Ring Door Bell/Chimes."""
from datetime import timedelta
+from enum import StrEnum, auto
import logging
from typing import Any
@@ -12,7 +13,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util
-from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
+from . import RingData
+from .const import DOMAIN
from .coordinator import RingDataCoordinator
from .entity import RingEntity, exception_wrap
@@ -26,8 +28,12 @@ _LOGGER = logging.getLogger(__name__)
SKIP_UPDATES_DELAY = timedelta(seconds=5)
-ON_STATE = "on"
-OFF_STATE = "off"
+
+class OnOffState(StrEnum):
+ """Enum for allowed on off states."""
+
+ ON = auto()
+ OFF = auto()
async def async_setup_entry(
@@ -36,56 +42,56 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the lights for the Ring devices."""
- devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
- devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
- RING_DEVICES_COORDINATOR
- ]
+ ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
+ devices_coordinator = ring_data.devices_coordinator
async_add_entities(
RingLight(device, devices_coordinator)
- for device in devices["stickup_cams"]
+ for device in ring_data.devices.stickup_cams
if device.has_capability("light")
)
-class RingLight(RingEntity, LightEntity):
+class RingLight(RingEntity[RingStickUpCam], LightEntity):
"""Creates a switch to turn the ring cameras light on and off."""
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_translation_key = "light"
- def __init__(self, device, coordinator):
+ def __init__(
+ self, device: RingStickUpCam, coordinator: RingDataCoordinator
+ ) -> None:
"""Initialize the light."""
super().__init__(device, coordinator)
self._attr_unique_id = str(device.id)
- self._attr_is_on = device.lights == ON_STATE
+ self._attr_is_on = device.lights == OnOffState.ON
self._no_updates_until = dt_util.utcnow()
@callback
- def _handle_coordinator_update(self):
+ def _handle_coordinator_update(self) -> None:
"""Call update method."""
if self._no_updates_until > dt_util.utcnow():
return
- if (device := self._get_coordinator_device()) and isinstance(
- device, RingStickUpCam
- ):
- self._attr_is_on = device.lights == ON_STATE
+ device = self._get_coordinator_data().get_stickup_cam(
+ self._device.device_api_id
+ )
+ self._attr_is_on = device.lights == OnOffState.ON
super()._handle_coordinator_update()
@exception_wrap
- def _set_light(self, new_state):
+ def _set_light(self, new_state: OnOffState) -> None:
"""Update light state, and causes Home Assistant to correctly update."""
self._device.lights = new_state
- self._attr_is_on = new_state == ON_STATE
+ self._attr_is_on = new_state == OnOffState.ON
self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY
self.schedule_update_ha_state()
def turn_on(self, **kwargs: Any) -> None:
"""Turn the light on for 30 seconds."""
- self._set_light(ON_STATE)
+ self._set_light(OnOffState.ON)
def turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
- self._set_light(OFF_STATE)
+ self._set_light(OnOffState.OFF)
diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py
index 9ba677e7e5b..b6849e37d96 100644
--- a/homeassistant/components/ring/sensor.py
+++ b/homeassistant/components/ring/sensor.py
@@ -2,10 +2,18 @@
from __future__ import annotations
+from collections.abc import Callable
from dataclasses import dataclass
-from typing import Any
+from typing import Any, Generic, cast
-from ring_doorbell import RingGeneric
+from ring_doorbell import (
+ RingCapability,
+ RingChime,
+ RingDoorBell,
+ RingEventKind,
+ RingGeneric,
+ RingOther,
+)
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -21,10 +29,12 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
-from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
+from . import RingData
+from .const import DOMAIN
from .coordinator import RingDataCoordinator
-from .entity import RingEntity
+from .entity import RingDeviceT, RingEntity
async def async_setup_entry(
@@ -33,209 +43,192 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a sensor for a Ring device."""
- devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
- devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
- RING_DEVICES_COORDINATOR
- ]
+ ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
+ devices_coordinator = ring_data.devices_coordinator
entities = [
- description.cls(device, devices_coordinator, description)
- for device_type in (
- "chimes",
- "doorbots",
- "authorized_doorbots",
- "stickup_cams",
- "other",
- )
+ RingSensor(device, devices_coordinator, description)
for description in SENSOR_TYPES
- if device_type in description.category
- for device in devices[device_type]
- if not (device_type == "battery" and device.battery_life is None)
+ for device in ring_data.devices.all_devices
+ if description.exists_fn(device)
]
async_add_entities(entities)
-class RingSensor(RingEntity, SensorEntity):
+class RingSensor(RingEntity[RingDeviceT], SensorEntity):
"""A sensor implementation for Ring device."""
- entity_description: RingSensorEntityDescription
+ entity_description: RingSensorEntityDescription[RingDeviceT]
def __init__(
self,
- device: RingGeneric,
+ device: RingDeviceT,
coordinator: RingDataCoordinator,
- description: RingSensorEntityDescription,
+ description: RingSensorEntityDescription[RingDeviceT],
) -> None:
"""Initialize a sensor for Ring device."""
super().__init__(device, coordinator)
self.entity_description = description
self._attr_unique_id = f"{device.id}-{description.key}"
-
- @property
- def native_value(self):
- """Return the state of the sensor."""
- sensor_type = self.entity_description.key
- if sensor_type == "volume":
- return self._device.volume
- if sensor_type == "doorbell_volume":
- return self._device.doorbell_volume
- if sensor_type == "mic_volume":
- return self._device.mic_volume
- if sensor_type == "voice_volume":
- return self._device.voice_volume
-
- if sensor_type == "battery":
- return self._device.battery_life
-
-
-class HealthDataRingSensor(RingSensor):
- """Ring sensor that relies on health data."""
-
- # These sensors are data hungry and not useful. Disable by default.
- _attr_entity_registry_enabled_default = False
-
- @property
- def native_value(self):
- """Return the state of the sensor."""
- sensor_type = self.entity_description.key
- if sensor_type == "wifi_signal_category":
- return self._device.wifi_signal_category
-
- if sensor_type == "wifi_signal_strength":
- return self._device.wifi_signal_strength
-
-
-class HistoryRingSensor(RingSensor):
- """Ring sensor that relies on history data."""
-
- _latest_event: dict[str, Any] | None = None
+ self._attr_entity_registry_enabled_default = (
+ description.entity_registry_enabled_default
+ )
+ self._attr_native_value = self.entity_description.value_fn(self._device)
@callback
- def _handle_coordinator_update(self):
+ def _handle_coordinator_update(self) -> None:
"""Call update method."""
- if not (history_data := self._get_coordinator_history()):
- return
- kind = self.entity_description.kind
- found = None
- if kind is None:
- found = history_data[0]
- else:
- for entry in history_data:
- if entry["kind"] == kind:
- found = entry
- break
-
- if not found:
- return
-
- self._latest_event = found
+ self._device = cast(
+ RingDeviceT,
+ self._get_coordinator_data().get_device(self._device.device_api_id),
+ )
+ # History values can drop off the last 10 events so only update
+ # the value if it's not None
+ if native_value := self.entity_description.value_fn(self._device):
+ self._attr_native_value = native_value
+ if extra_attrs := self.entity_description.extra_state_attributes_fn(
+ self._device
+ ):
+ self._attr_extra_state_attributes = extra_attrs
super()._handle_coordinator_update()
- @property
- def native_value(self):
- """Return the state of the sensor."""
- if self._latest_event is None:
- return None
- return self._latest_event["created_at"]
+def _get_last_event(
+ history_data: list[dict[str, Any]], kind: RingEventKind | None
+) -> dict[str, Any] | None:
+ if not history_data:
+ return None
+ if kind is None:
+ return history_data[0]
+ for entry in history_data:
+ if entry["kind"] == kind.value:
+ return entry
+ return None
- @property
- def extra_state_attributes(self):
- """Return the state attributes."""
- attrs = super().extra_state_attributes
- if self._latest_event:
- attrs["created_at"] = self._latest_event["created_at"]
- attrs["answered"] = self._latest_event["answered"]
- attrs["recording_status"] = self._latest_event["recording"]["status"]
- attrs["category"] = self._latest_event["kind"]
-
- return attrs
+def _get_last_event_attrs(
+ history_data: list[dict[str, Any]], kind: RingEventKind | None
+) -> dict[str, Any] | None:
+ if last_event := _get_last_event(history_data, kind):
+ return {
+ "created_at": last_event.get("created_at"),
+ "answered": last_event.get("answered"),
+ "recording_status": last_event.get("recording", {}).get("status"),
+ "category": last_event.get("kind"),
+ }
+ return None
@dataclass(frozen=True, kw_only=True)
-class RingSensorEntityDescription(SensorEntityDescription):
+class RingSensorEntityDescription(SensorEntityDescription, Generic[RingDeviceT]):
"""Describes Ring sensor entity."""
- category: list[str]
- cls: type[RingSensor]
-
- kind: str | None = None
+ value_fn: Callable[[RingDeviceT], StateType] = lambda _: True
+ exists_fn: Callable[[RingGeneric], bool] = lambda _: True
+ extra_state_attributes_fn: Callable[[RingDeviceT], dict[str, Any] | None] = (
+ lambda _: None
+ )
-SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = (
- RingSensorEntityDescription(
+# For some reason mypy doesn't properly type check the default TypeVar value here
+# so for now the [RingGeneric] subscript needs to be specified.
+# Once https://github.com/python/mypy/issues/14851 is closed this should hopefully
+# be fixed and the [RingGeneric] subscript can be removed.
+# https://github.com/home-assistant/core/pull/115276#discussion_r1560106576
+SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = (
+ RingSensorEntityDescription[RingGeneric](
key="battery",
- category=["doorbots", "authorized_doorbots", "stickup_cams", "other"],
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
- cls=RingSensor,
+ value_fn=lambda device: device.battery_life,
+ exists_fn=lambda device: device.family != "chimes",
),
- RingSensorEntityDescription(
+ RingSensorEntityDescription[RingGeneric](
key="last_activity",
translation_key="last_activity",
- category=["doorbots", "authorized_doorbots", "stickup_cams", "other"],
device_class=SensorDeviceClass.TIMESTAMP,
- cls=HistoryRingSensor,
+ value_fn=lambda device: last_event.get("created_at")
+ if (last_event := _get_last_event(device.last_history, None))
+ else None,
+ extra_state_attributes_fn=lambda device: last_event_attrs
+ if (last_event_attrs := _get_last_event_attrs(device.last_history, None))
+ else None,
+ exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
),
- RingSensorEntityDescription(
+ RingSensorEntityDescription[RingGeneric](
key="last_ding",
translation_key="last_ding",
- category=["doorbots", "authorized_doorbots", "other"],
- kind="ding",
device_class=SensorDeviceClass.TIMESTAMP,
- cls=HistoryRingSensor,
+ value_fn=lambda device: last_event.get("created_at")
+ if (last_event := _get_last_event(device.last_history, RingEventKind.DING))
+ else None,
+ extra_state_attributes_fn=lambda device: last_event_attrs
+ if (
+ last_event_attrs := _get_last_event_attrs(
+ device.last_history, RingEventKind.DING
+ )
+ )
+ else None,
+ exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
),
- RingSensorEntityDescription(
+ RingSensorEntityDescription[RingGeneric](
key="last_motion",
translation_key="last_motion",
- category=["doorbots", "authorized_doorbots", "stickup_cams"],
- kind="motion",
device_class=SensorDeviceClass.TIMESTAMP,
- cls=HistoryRingSensor,
+ value_fn=lambda device: last_event.get("created_at")
+ if (last_event := _get_last_event(device.last_history, RingEventKind.MOTION))
+ else None,
+ extra_state_attributes_fn=lambda device: last_event_attrs
+ if (
+ last_event_attrs := _get_last_event_attrs(
+ device.last_history, RingEventKind.MOTION
+ )
+ )
+ else None,
+ exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
),
- RingSensorEntityDescription(
+ RingSensorEntityDescription[RingDoorBell | RingChime](
key="volume",
translation_key="volume",
- category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"],
- cls=RingSensor,
+ value_fn=lambda device: device.volume,
+ exists_fn=lambda device: isinstance(device, (RingDoorBell, RingChime)),
),
- RingSensorEntityDescription(
+ RingSensorEntityDescription[RingOther](
key="doorbell_volume",
translation_key="doorbell_volume",
- category=["other"],
- cls=RingSensor,
+ value_fn=lambda device: device.doorbell_volume,
+ exists_fn=lambda device: isinstance(device, RingOther),
),
- RingSensorEntityDescription(
+ RingSensorEntityDescription[RingOther](
key="mic_volume",
translation_key="mic_volume",
- category=["other"],
- cls=RingSensor,
+ value_fn=lambda device: device.mic_volume,
+ exists_fn=lambda device: isinstance(device, RingOther),
),
- RingSensorEntityDescription(
+ RingSensorEntityDescription[RingOther](
key="voice_volume",
translation_key="voice_volume",
- category=["other"],
- cls=RingSensor,
+ value_fn=lambda device: device.voice_volume,
+ exists_fn=lambda device: isinstance(device, RingOther),
),
- RingSensorEntityDescription(
+ RingSensorEntityDescription[RingGeneric](
key="wifi_signal_category",
translation_key="wifi_signal_category",
- category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams", "other"],
entity_category=EntityCategory.DIAGNOSTIC,
- cls=HealthDataRingSensor,
+ entity_registry_enabled_default=False,
+ value_fn=lambda device: device.wifi_signal_category,
),
- RingSensorEntityDescription(
+ RingSensorEntityDescription[RingGeneric](
key="wifi_signal_strength",
translation_key="wifi_signal_strength",
- category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams", "other"],
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC,
- cls=HealthDataRingSensor,
+ entity_registry_enabled_default=False,
+ value_fn=lambda device: device.wifi_signal_strength,
),
)
diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py
index 4b7d9243dbf..f63f9d33182 100644
--- a/homeassistant/components/ring/siren.py
+++ b/homeassistant/components/ring/siren.py
@@ -1,15 +1,17 @@
"""Component providing HA Siren support for Ring Chimes."""
import logging
+from typing import Any
-from ring_doorbell.const import CHIME_TEST_SOUND_KINDS, KIND_DING
+from ring_doorbell import RingChime, RingEventKind
from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
+from . import RingData
+from .const import DOMAIN
from .coordinator import RingDataCoordinator
from .entity import RingEntity, exception_wrap
@@ -22,32 +24,31 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the sirens for the Ring devices."""
- devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
- coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
- RING_DEVICES_COORDINATOR
- ]
+ ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
+ devices_coordinator = ring_data.devices_coordinator
async_add_entities(
- RingChimeSiren(device, coordinator) for device in devices["chimes"]
+ RingChimeSiren(device, devices_coordinator)
+ for device in ring_data.devices.chimes
)
-class RingChimeSiren(RingEntity, SirenEntity):
+class RingChimeSiren(RingEntity[RingChime], SirenEntity):
"""Creates a siren to play the test chimes of a Chime device."""
- _attr_available_tones = list(CHIME_TEST_SOUND_KINDS)
+ _attr_available_tones = [RingEventKind.DING.value, RingEventKind.MOTION.value]
_attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES
_attr_translation_key = "siren"
- def __init__(self, device, coordinator: RingDataCoordinator) -> None:
+ def __init__(self, device: RingChime, coordinator: RingDataCoordinator) -> None:
"""Initialize a Ring Chime siren."""
super().__init__(device, coordinator)
# Entity class attributes
self._attr_unique_id = f"{self._device.id}-siren"
@exception_wrap
- def turn_on(self, **kwargs):
+ def turn_on(self, **kwargs: Any) -> None:
"""Play the test sound on a Ring Chime device."""
- tone = kwargs.get(ATTR_TONE) or KIND_DING
+ tone = kwargs.get(ATTR_TONE) or RingEventKind.DING.value
self._device.test_sound(kind=tone)
diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py
index 2221eeb7705..0e032907bae 100644
--- a/homeassistant/components/ring/switch.py
+++ b/homeassistant/components/ring/switch.py
@@ -4,7 +4,7 @@ from datetime import timedelta
import logging
from typing import Any
-from ring_doorbell import RingGeneric, RingStickUpCam
+from ring_doorbell import RingStickUpCam
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
@@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util
-from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
+from . import RingData
+from .const import DOMAIN
from .coordinator import RingDataCoordinator
from .entity import RingEntity, exception_wrap
@@ -33,23 +34,21 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the switches for the Ring devices."""
- devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
- coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
- RING_DEVICES_COORDINATOR
- ]
+ ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
+ devices_coordinator = ring_data.devices_coordinator
async_add_entities(
- SirenSwitch(device, coordinator)
- for device in devices["stickup_cams"]
+ SirenSwitch(device, devices_coordinator)
+ for device in ring_data.devices.stickup_cams
if device.has_capability("siren")
)
-class BaseRingSwitch(RingEntity, SwitchEntity):
+class BaseRingSwitch(RingEntity[RingStickUpCam], SwitchEntity):
"""Represents a switch for controlling an aspect of a ring device."""
def __init__(
- self, device: RingGeneric, coordinator: RingDataCoordinator, device_type: str
+ self, device: RingStickUpCam, coordinator: RingDataCoordinator, device_type: str
) -> None:
"""Initialize the switch."""
super().__init__(device, coordinator)
@@ -62,26 +61,27 @@ class SirenSwitch(BaseRingSwitch):
_attr_translation_key = "siren"
- def __init__(self, device, coordinator: RingDataCoordinator) -> None:
+ def __init__(
+ self, device: RingStickUpCam, coordinator: RingDataCoordinator
+ ) -> None:
"""Initialize the switch for a device with a siren."""
super().__init__(device, coordinator, "siren")
self._no_updates_until = dt_util.utcnow()
self._attr_is_on = device.siren > 0
@callback
- def _handle_coordinator_update(self):
+ def _handle_coordinator_update(self) -> None:
"""Call update method."""
if self._no_updates_until > dt_util.utcnow():
return
-
- if (device := self._get_coordinator_device()) and isinstance(
- device, RingStickUpCam
- ):
- self._attr_is_on = device.siren > 0
+ device = self._get_coordinator_data().get_stickup_cam(
+ self._device.device_api_id
+ )
+ self._attr_is_on = device.siren > 0
super()._handle_coordinator_update()
@exception_wrap
- def _set_switch(self, new_state):
+ def _set_switch(self, new_state: int) -> None:
"""Update switch state, and causes Home Assistant to correctly update."""
self._device.siren = new_state
diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py
index 7ca18ea77c5..d25579343c8 100644
--- a/homeassistant/components/risco/__init__.py
+++ b/homeassistant/components/risco/__init__.py
@@ -101,7 +101,7 @@ async def _async_setup_local_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
return False
async def _error(error: Exception) -> None:
- _LOGGER.error("Error in Risco library: %s", error)
+ _LOGGER.error("Error in Risco library", exc_info=error)
entry.async_on_unload(risco.add_error_handler(_error))
diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json
index 711da78de31..d03aa68f1a6 100644
--- a/homeassistant/components/roborock/manifest.json
+++ b/homeassistant/components/roborock/manifest.json
@@ -7,7 +7,7 @@
"iot_class": "local_polling",
"loggers": ["roborock"],
"requirements": [
- "python-roborock==1.0.0",
+ "python-roborock==2.0.0",
"vacuum-map-parser-roborock==0.1.1"
]
}
diff --git a/homeassistant/components/teslemetry/diagnostics.py b/homeassistant/components/teslemetry/diagnostics.py
new file mode 100644
index 00000000000..f8a8e6727a7
--- /dev/null
+++ b/homeassistant/components/teslemetry/diagnostics.py
@@ -0,0 +1,46 @@
+"""Provides diagnostics for Teslemetry."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from .const import DOMAIN
+
+VEHICLE_REDACT = [
+ "id",
+ "user_id",
+ "vehicle_id",
+ "vin",
+ "tokens",
+ "id_s",
+ "drive_state_active_route_latitude",
+ "drive_state_active_route_longitude",
+ "drive_state_latitude",
+ "drive_state_longitude",
+ "drive_state_native_latitude",
+ "drive_state_native_longitude",
+]
+
+ENERGY_REDACT = ["vin"]
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ vehicles = [
+ x.coordinator.data for x in hass.data[DOMAIN][config_entry.entry_id].vehicles
+ ]
+ energysites = [
+ x.coordinator.data for x in hass.data[DOMAIN][config_entry.entry_id].energysites
+ ]
+
+ # Return only the relevant children
+ return {
+ "vehicles": async_redact_data(vehicles, VEHICLE_REDACT),
+ "energysites": async_redact_data(energysites, ENERGY_REDACT),
+ }
diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json
index ee7861588ed..5618a3f61cb 100644
--- a/homeassistant/components/whirlpool/manifest.json
+++ b/homeassistant/components/whirlpool/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["whirlpool"],
- "requirements": ["whirlpool-sixth-sense==0.18.7"]
+ "requirements": ["whirlpool-sixth-sense==0.18.8"]
}
diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py
index aee25da507c..c90455de7ec 100644
--- a/homeassistant/components/withings/config_flow.py
+++ b/homeassistant/components/withings/config_flow.py
@@ -34,14 +34,8 @@ class WithingsFlowHandler(
def extra_authorize_data(self) -> dict[str, str]:
"""Extra data that needs to be appended to the authorize url."""
return {
- "scope": ",".join(
- [
- AuthScope.USER_INFO,
- AuthScope.USER_METRICS,
- AuthScope.USER_ACTIVITY,
- AuthScope.USER_SLEEP_EVENTS,
- ]
- )
+ "scope": f"{AuthScope.USER_INFO},{AuthScope.USER_METRICS},"
+ f"{AuthScope.USER_ACTIVITY},{AuthScope.USER_SLEEP_EVENTS}"
}
async def async_step_reauth(
diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py
index 077a6710b8d..f25cf41b992 100644
--- a/homeassistant/components/workday/__init__.py
+++ b/homeassistant/components/workday/__init__.py
@@ -11,6 +11,7 @@ from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
+from homeassistant.setup import SetupPhases, async_pause_setup
from .const import CONF_PROVINCE, DOMAIN, PLATFORMS
@@ -23,7 +24,11 @@ async def _async_validate_country_and_province(
if not country:
return
try:
- await hass.async_add_executor_job(country_holidays, country)
+ with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES):
+ # import executor job is used here because multiple integrations use
+ # the holidays library and it is not thread safe to import it in parallel
+ # https://github.com/python/cpython/issues/83065
+ await hass.async_add_import_executor_job(country_holidays, country)
except NotImplementedError as ex:
async_create_issue(
hass,
@@ -41,9 +46,13 @@ async def _async_validate_country_and_province(
if not province:
return
try:
- await hass.async_add_executor_job(
- partial(country_holidays, country, subdiv=province)
- )
+ with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES):
+ # import executor job is used here because multiple integrations use
+ # the holidays library and it is not thread safe to import it in parallel
+ # https://github.com/python/cpython/issues/83065
+ await hass.async_add_import_executor_job(
+ partial(country_holidays, country, subdiv=province)
+ )
except NotImplementedError as ex:
async_create_issue(
hass,
@@ -73,9 +82,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await _async_validate_country_and_province(hass, entry, country, province)
if country and CONF_LANGUAGE not in entry.options:
- cls: HolidayBase = await hass.async_add_executor_job(
- partial(country_holidays, country, subdiv=province)
- )
+ with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES):
+ # import executor job is used here because multiple integrations use
+ # the holidays library and it is not thread safe to import it in parallel
+ # https://github.com/python/cpython/issues/83065
+ cls: HolidayBase = await hass.async_add_import_executor_job(
+ partial(country_holidays, country, subdiv=province)
+ )
default_language = cls.default_language
new_options = entry.options.copy()
new_options[CONF_LANGUAGE] = default_language
diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py
index 7b4c06ffb62..bbc89e77a76 100644
--- a/homeassistant/components/zeroconf/__init__.py
+++ b/homeassistant/components/zeroconf/__init__.py
@@ -428,7 +428,6 @@ class ZeroconfDiscovery:
zeroconf, async_service_info, service_type, name
),
name=f"zeroconf lookup {name}.{service_type}",
- eager_start=False,
)
async def _async_lookup_and_process_service_update(
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
index 7c489517dd7..0a76af3b9c2 100644
--- a/homeassistant/components/zeroconf/manifest.json
+++ b/homeassistant/components/zeroconf/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["zeroconf"],
"quality_scale": "internal",
- "requirements": ["zeroconf==0.132.0"]
+ "requirements": ["zeroconf==0.132.2"]
}
diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py
index 777d45efddb..3d61699472d 100644
--- a/homeassistant/components/zwave_js/diagnostics.py
+++ b/homeassistant/components/zwave_js/diagnostics.py
@@ -151,7 +151,8 @@ async def async_get_device_diagnostics(
client: Client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
identifiers = get_home_and_node_id_from_device_entry(device)
node_id = identifiers[1] if identifiers else None
- assert (driver := client.driver)
+ driver = client.driver
+ assert driver
if node_id is None or node_id not in driver.controller.nodes:
raise ValueError(f"Node for device {device.id} can't be found")
node = driver.controller.nodes[node_id]
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index dd48c53160e..7c1b590b1b0 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -282,7 +282,23 @@ class ConfigEntry:
pref_disable_new_entities: bool
pref_disable_polling: bool
version: int
+ source: str
minor_version: int
+ disabled_by: ConfigEntryDisabler | None
+ supports_unload: bool | None
+ supports_remove_device: bool | None
+ _supports_options: bool | None
+ _supports_reconfigure: bool | None
+ update_listeners: list[UpdateListenerType]
+ _async_cancel_retry_setup: Callable[[], Any] | None
+ _on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None
+ reload_lock: asyncio.Lock
+ _reauth_lock: asyncio.Lock
+ _reconfigure_lock: asyncio.Lock
+ _tasks: set[asyncio.Future[Any]]
+ _background_tasks: set[asyncio.Future[Any]]
+ _integration_for_domain: loader.Integration | None
+ _tries: int
def __init__(
self,
@@ -334,7 +350,7 @@ class ConfigEntry:
_setter(self, "pref_disable_polling", pref_disable_polling)
# Source of the configuration (user, discovery, cloud)
- self.source = source
+ _setter(self, "source", source)
# State of the entry (LOADED, NOT_LOADED)
_setter(self, "state", state)
@@ -355,22 +371,22 @@ class ConfigEntry:
error_if_core=False,
)
disabled_by = ConfigEntryDisabler(disabled_by)
- self.disabled_by = disabled_by
+ _setter(self, "disabled_by", disabled_by)
# Supports unload
- self.supports_unload: bool | None = None
+ _setter(self, "supports_unload", None)
# Supports remove device
- self.supports_remove_device: bool | None = None
+ _setter(self, "supports_remove_device", None)
# Supports options
- self._supports_options: bool | None = None
+ _setter(self, "_supports_options", None)
# Supports reconfigure
- self._supports_reconfigure: bool | None = None
+ _setter(self, "_supports_reconfigure", None)
# Listeners to call on update
- self.update_listeners: list[UpdateListenerType] = []
+ _setter(self, "update_listeners", [])
# Reason why config entry is in a failed state
_setter(self, "reason", None)
@@ -378,25 +394,23 @@ class ConfigEntry:
_setter(self, "error_reason_translation_placeholders", None)
# Function to cancel a scheduled retry
- self._async_cancel_retry_setup: Callable[[], Any] | None = None
+ _setter(self, "_async_cancel_retry_setup", None)
# Hold list for actions to call on unload.
- self._on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None = (
- None
- )
+ _setter(self, "_on_unload", None)
# Reload lock to prevent conflicting reloads
- self.reload_lock = asyncio.Lock()
+ _setter(self, "reload_lock", asyncio.Lock())
# Reauth lock to prevent concurrent reauth flows
- self._reauth_lock = asyncio.Lock()
+ _setter(self, "_reauth_lock", asyncio.Lock())
# Reconfigure lock to prevent concurrent reconfigure flows
- self._reconfigure_lock = asyncio.Lock()
+ _setter(self, "_reconfigure_lock", asyncio.Lock())
- self._tasks: set[asyncio.Future[Any]] = set()
- self._background_tasks: set[asyncio.Future[Any]] = set()
+ _setter(self, "_tasks", set())
+ _setter(self, "_background_tasks", set())
- self._integration_for_domain: loader.Integration | None = None
- self._tries = 0
+ _setter(self, "_integration_for_domain", None)
+ _setter(self, "_tries", 0)
def __repr__(self) -> str:
"""Representation of ConfigEntry."""
diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py
index 649c9fdf8a4..7e7019681af 100644
--- a/homeassistant/data_entry_flow.py
+++ b/homeassistant/data_entry_flow.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import abc
import asyncio
-from collections.abc import Callable, Iterable, Mapping
+from collections.abc import Callable, Container, Iterable, Mapping
from contextlib import suppress
import copy
from dataclasses import dataclass
@@ -153,7 +153,7 @@ class FlowResult(TypedDict, Generic[_HandlerT], total=False):
flow_id: Required[str]
handler: Required[_HandlerT]
last_step: bool | None
- menu_options: list[str] | dict[str, str]
+ menu_options: Container[str]
options: Mapping[str, Any]
preview: str | None
progress_action: str
@@ -843,7 +843,7 @@ class FlowHandler(Generic[_FlowResultT, _HandlerT]):
self,
*,
step_id: str | None = None,
- menu_options: list[str] | dict[str, str],
+ menu_options: Container[str],
description_placeholders: Mapping[str, str] | None = None,
) -> _FlowResultT:
"""Show a navigation menu to the user.
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 667639226a1..20fbc883207 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -957,7 +957,8 @@
"color_extractor": {
"name": "ColorExtractor",
"integration_type": "hub",
- "config_flow": true
+ "config_flow": true,
+ "single_config_entry": true
},
"comed": {
"name": "Commonwealth Edison (ComEd)",
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index 261512c14af..2b9a5d436ed 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -801,7 +801,7 @@ class EntityPlatform:
get_initial_options=entity.get_initial_entity_options,
has_entity_name=entity.has_entity_name,
hidden_by=hidden_by,
- known_object_ids=self.entities.keys(),
+ known_object_ids=self.entities,
original_device_class=entity.device_class,
original_icon=entity.icon,
original_name=entity_name,
@@ -839,11 +839,13 @@ class EntityPlatform:
if self.entity_namespace is not None:
suggested_object_id = f"{self.entity_namespace} {suggested_object_id}"
entity.entity_id = entity_registry.async_generate_entity_id(
- self.domain, suggested_object_id, self.entities.keys()
+ self.domain, suggested_object_id, self.entities
)
# Make sure it is valid in case an entity set the value themselves
- if not valid_entity_id(entity.entity_id):
+ # Avoid calling valid_entity_id if we already know it is valid
+ # since it already made it in the registry
+ if not entity.registry_entry and not valid_entity_id(entity.entity_id):
entity.add_to_platform_abort()
raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}")
diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py
index d6e7395a2cb..3a26505c7da 100644
--- a/homeassistant/helpers/entity_registry.py
+++ b/homeassistant/helpers/entity_registry.py
@@ -10,7 +10,7 @@ timer.
from __future__ import annotations
-from collections.abc import Callable, Hashable, Iterable, KeysView, Mapping
+from collections.abc import Callable, Container, Hashable, KeysView, Mapping
from datetime import datetime, timedelta
from enum import StrEnum
from functools import cached_property
@@ -714,7 +714,7 @@ class EntityRegistry(BaseRegistry):
return list(self.entities.get_device_ids())
def _entity_id_available(
- self, entity_id: str, known_object_ids: Iterable[str] | None
+ self, entity_id: str, known_object_ids: Container[str] | None
) -> bool:
"""Return True if the entity_id is available.
@@ -740,7 +740,7 @@ class EntityRegistry(BaseRegistry):
self,
domain: str,
suggested_object_id: str,
- known_object_ids: Iterable[str] | None = None,
+ known_object_ids: Container[str] | None = None,
) -> str:
"""Generate an entity ID that does not conflict.
@@ -753,7 +753,7 @@ class EntityRegistry(BaseRegistry):
test_string = preferred_string[:MAX_LENGTH_STATE_ENTITY_ID]
if known_object_ids is None:
- known_object_ids = {}
+ known_object_ids = set()
tries = 1
while not self._entity_id_available(test_string, known_object_ids):
@@ -773,7 +773,7 @@ class EntityRegistry(BaseRegistry):
unique_id: str,
*,
# To influence entity ID generation
- known_object_ids: Iterable[str] | None = None,
+ known_object_ids: Container[str] | None = None,
suggested_object_id: str | None = None,
# To disable or hide an entity if it gets created
disabled_by: RegistryEntryDisabler | None = None,
diff --git a/homeassistant/helpers/importlib.py b/homeassistant/helpers/importlib.py
index 00af75f6d8e..98c75939084 100644
--- a/homeassistant/helpers/importlib.py
+++ b/homeassistant/helpers/importlib.py
@@ -30,11 +30,9 @@ async def async_import_module(hass: HomeAssistant, name: str) -> ModuleType:
if module := cache.get(name):
return module
- failure_cache: dict[str, BaseException] = hass.data.setdefault(
- DATA_IMPORT_FAILURES, {}
- )
- if exception := failure_cache.get(name):
- raise exception
+ failure_cache: dict[str, bool] = hass.data.setdefault(DATA_IMPORT_FAILURES, {})
+ if name in failure_cache:
+ raise ModuleNotFoundError(f"{name} not found", name=name)
import_futures: dict[str, asyncio.Future[ModuleType]]
import_futures = hass.data.setdefault(DATA_IMPORT_FUTURES, {})
@@ -51,7 +49,8 @@ async def async_import_module(hass: HomeAssistant, name: str) -> ModuleType:
module = await hass.async_add_import_executor_job(_get_module, cache, name)
import_future.set_result(module)
except BaseException as ex:
- failure_cache[name] = ex
+ if isinstance(ex, ModuleNotFoundError):
+ failure_cache[name] = True
import_future.set_exception(ex)
with suppress(BaseException):
# Set the exception retrieved flag on the future since
diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py
index 978ce949eb3..67624bfb368 100644
--- a/homeassistant/helpers/schema_config_entry_flow.py
+++ b/homeassistant/helpers/schema_config_entry_flow.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
-from collections.abc import Callable, Coroutine, Mapping
+from collections.abc import Callable, Container, Coroutine, Mapping
import copy
from dataclasses import dataclass
import types
@@ -102,7 +102,7 @@ class SchemaFlowMenuStep(SchemaFlowStep):
"""Define a config or options flow menu step."""
# Menu options
- options: list[str] | dict[str, str]
+ options: Container[str]
class SchemaCommonFlowHandler:
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index 3c364ed8892..ea5cc3e571a 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -1250,7 +1250,7 @@ async def _async_stop_scripts_after_shutdown(
_LOGGER.warning("Stopping scripts running too long after shutdown: %s", names)
await asyncio.gather(
*(
- script["instance"].async_stop(update_state=False)
+ create_eager_task(script["instance"].async_stop(update_state=False))
for script in running_scripts
)
)
@@ -1269,7 +1269,10 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) ->
names = ", ".join([script["instance"].name for script in running_scripts])
_LOGGER.debug("Stopping scripts running at shutdown: %s", names)
await asyncio.gather(
- *(script["instance"].async_stop() for script in running_scripts)
+ *(
+ create_eager_task(script["instance"].async_stop())
+ for script in running_scripts
+ )
)
@@ -1695,6 +1698,9 @@ class Script:
# return false after the other script runs were stopped until our task
# resumes running.
self._log("Restarting")
+ # Important: yield to the event loop to allow the script to start in case
+ # the script is restarting itself.
+ await asyncio.sleep(0)
await self.async_stop(update_state=False, spare=run)
if started_action:
@@ -1724,11 +1730,13 @@ class Script:
# asyncio.shield as asyncio.shield yields to the event loop, which would cause
# us to wait for script runs added after the call to async_stop.
aws = [
- asyncio.create_task(run.async_stop()) for run in self._runs if run != spare
+ create_eager_task(run.async_stop()) for run in self._runs if run != spare
]
if not aws:
return
- await asyncio.shield(self._async_stop(aws, update_state, spare))
+ await asyncio.shield(
+ create_eager_task(self._async_stop(aws, update_state, spare))
+ )
async def _async_get_condition(self, config):
if isinstance(config, template.Template):
diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py
index 9af02402bc0..31e0d3648db 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -93,6 +93,7 @@ def _base_components() -> dict[str, ModuleType]:
light,
lock,
media_player,
+ notify,
remote,
siren,
todo,
@@ -112,6 +113,7 @@ def _base_components() -> dict[str, ModuleType]:
"light": light,
"lock": lock,
"media_player": media_player,
+ "notify": notify,
"remote": remote,
"siren": siren,
"todo": todo,
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index da8159ca2cf..1a72c8eb351 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -976,6 +976,8 @@ class Integration:
comp = await self.hass.async_add_import_executor_job(
self._get_component, True
)
+ except ModuleNotFoundError:
+ raise
except ImportError as ex:
load_executor = False
_LOGGER.debug(
@@ -1115,6 +1117,8 @@ class Integration:
self._load_platforms, platform_names
)
)
+ except ModuleNotFoundError:
+ raise
except ImportError as ex:
_LOGGER.debug(
"Failed to import %s platforms %s in executor",
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 4acbe3fae58..07885c8a067 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -5,8 +5,9 @@ aiodiscover==2.0.0
aiodns==3.2.0
aiohttp-fast-url-dispatcher==0.3.0
aiohttp-zlib-ng==0.3.1
-aiohttp==3.9.3
+aiohttp==3.9.4
aiohttp_cors==0.7.0
+aiohttp_session==2.12.0
astral==2.2
async-interrupt==1.1.1
async-upnp-client==0.38.3
@@ -31,7 +32,7 @@ habluetooth==2.4.2
hass-nabucasa==0.78.0
hassil==1.6.1
home-assistant-bluetooth==1.12.0
-home-assistant-frontend==20240404.1
+home-assistant-frontend==20240404.2
home-assistant-intents==2024.4.3
httpx==0.27.0
ifaddr==0.2.0
@@ -61,7 +62,7 @@ voluptuous-serialize==2.6.0
voluptuous==0.13.1
webrtc-noise-gain==1.2.3
yarl==1.9.4
-zeroconf==0.132.0
+zeroconf==0.132.2
# Constrain pycryptodome to avoid vulnerability
# see https://github.com/home-assistant/core/pull/16238
diff --git a/homeassistant/py.typed b/homeassistant/py.typed
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/mypy.ini b/mypy.ini
index 159101a21b3..3e0419be269 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -3391,6 +3391,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.ring.*]
+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.rituals_perfume_genie.*]
check_untyped_defs = true
disallow_incomplete_defs = true
diff --git a/pyproject.toml b/pyproject.toml
index 66c82d2e770..b9111f505c2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,8 +24,9 @@ classifiers = [
requires-python = ">=3.12.0"
dependencies = [
"aiodns==3.2.0",
- "aiohttp==3.9.3",
+ "aiohttp==3.9.4",
"aiohttp_cors==0.7.0",
+ "aiohttp_session==2.12.0",
"aiohttp-fast-url-dispatcher==0.3.0",
"aiohttp-zlib-ng==0.3.1",
"astral==2.2",
@@ -488,6 +489,8 @@ filterwarnings = [
"ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators",
# https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0
"ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base",
+ # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat",
# https://github.com/fwestenberg/devialet/pull/6 - >1.4.5
"ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api",
# https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0
@@ -504,13 +507,23 @@ filterwarnings = [
"ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol",
# https://github.com/hunterjm/python-onvif-zeep-async/pull/51 - >3.1.12
"ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client",
+ # https://github.com/googleapis/python-pubsub/commit/060f00bcea5cd129be3a2d37078535cc97b4f5e8 - >=2.13.12
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:google.pubsub_v1.services.publisher.client",
# https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0
"ignore:invalid escape sequence:SyntaxWarning:.*stringcase",
# https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3
"ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas",
+ # https://github.com/timmo001/system-bridge-connector/pull/27 - >= 4.1.0
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:systembridgeconnector.version",
+ # https://github.com/jschlyter/ttls/commit/d64f1251397b8238cf6a35bea64784de25e3386c - >=1.8.1
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:ttls",
# https://github.com/mvantellingen/python-zeep/pull/1364 - >4.2.1
"ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:zeep.utils",
+ # -- fixed for Python 3.13
+ # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2
+ "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:wyoming.audio",
+
# -- other
# Locale changes might take some time to resolve upstream
"ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud",
@@ -537,15 +550,47 @@ filterwarnings = [
# https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15
# https://github.com/koolsb/pyblackbird/pull/9 -> closed
"ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird",
- # https://pypi.org/project/pybotvac/ - v0.0.24 - 2023-01-02
- # https://github.com/stianaske/pybotvac/pull/81 -> closed
- "ignore:invalid escape sequence:SyntaxWarning:.*pybotvac.robot",
- # https://github.com/pkkid/python-plexapi/pull/1244 - v4.15.10 -> new issue same file
+ # https://github.com/pkkid/python-plexapi/pull/1244 - v4.15.11 -> new issue same file
+ # https://github.com/pkkid/python-plexapi/pull/1370 -> Not fixed here
"ignore:invalid escape sequence:SyntaxWarning:.*plexapi.base",
# https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05
"ignore:invalid escape sequence:SyntaxWarning:.*pyws66i",
# https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18
"ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil",
+ # - pkg_resources
+ # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast",
+ # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data",
+ # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version",
+ # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom",
+ # https://pypi.org/project/velbus-aio/ - v2024.4.0
+ # https://github.com/Cereal2nd/velbus-aio/blob/2024.4.0/velbusaio/handler.py#L13
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:velbusaio.handler",
+
+ # -- Python 3.13
+ # HomeAssistant
+ "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api",
+ # https://pypi.org/project/pylutron/ - v0.2.12 - 2024-02-12
+ # https://github.com/thecynic/pylutron/issues/89
+ "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pylutron",
+ # https://pypi.org/project/SpeechRecognition/ - v3.10.3 - 2024-03-30
+ # https://github.com/Uberi/speech_recognition/blob/3.10.3/speech_recognition/__init__.py#L7
+ "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition",
+ # https://pypi.org/project/voip-utils/ - v0.1.0 - 2023-06-28
+ # https://github.com/home-assistant-libs/voip-utils/blob/v0.1.0/voip_utils/rtp_audio.py#L2
+ "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio",
+
+ # -- Python 3.13 - unmaintained projects, last release about 2+ years
+ # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10
+ "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pydub.utils",
+ # https://github.com/heathbar/plum-lightpad-python/issues/7 - v0.0.11 - 2018-10-16
+ "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:plumlightpad.lightpad",
+ # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05
+ # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2
+ "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i",
# -- unmaintained projects, last release about 2+ years
# https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04
@@ -559,6 +604,10 @@ filterwarnings = [
"ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models",
# https://pypi.org/project/foobot_async/ - v1.0.0 - 2020-11-24
"ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async",
+ # https://pypi.org/project/habitipy/ - v0.3.0 - 2019-01-14
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api",
+ # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig",
# https://pypi.org/project/influxdb/ - v5.3.1 - 2020-11-11 (archived)
"ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol",
# https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark`
@@ -575,6 +624,8 @@ filterwarnings = [
"ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder",
# https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08
"ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils",
+ # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight",
# https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16
"ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery",
"ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)",
@@ -586,6 +637,10 @@ filterwarnings = [
"ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss",
# https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16
"ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann",
+ # https://pypi.org/project/pyowm/ - v3.3.0 - 2022-02-14
+ # https://github.com/csparpa/pyowm/issues/435
+ # https://github.com/csparpa/pyowm/blob/3.3.0/pyowm/commons/cityidregistry.py#L7
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pyowm.commons.cityidregistry",
# https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21
"ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils",
# https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19
@@ -604,7 +659,7 @@ filterwarnings = [
]
[tool.ruff]
-required-version = ">=0.3.4"
+required-version = ">=0.3.7"
[tool.ruff.lint]
select = [
@@ -625,6 +680,7 @@ select = [
"DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
"E", # pycodestyle
"F", # pyflakes/autoflake
+ "FLY", # flynt
"G", # flake8-logging-format
"I", # isort
"INP", # flake8-no-pep420
@@ -645,6 +701,7 @@ select = [
"RUF005", # Consider iterable unpacking instead of concatenation
"RUF006", # Store a reference to the return value of asyncio.create_task
"RUF013", # PEP 484 prohibits implicit Optional
+ "RUF018", # Avoid assignment expressions in assert statements
# "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up
"S102", # Use of exec detected
"S103", # bad-file-permissions
@@ -694,6 +751,10 @@ ignore = [
"PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception
"PT012", # `pytest.raises()` block should contain a single simple statement
"PT018", # Assertion should be broken down into multiple parts
+ "RUF001", # String contains ambiguous unicode character.
+ "RUF002", # Docstring contains ambiguous unicode character.
+ "RUF003", # Comment contains ambiguous unicode character.
+ "RUF015", # Prefer next(...) over single element slice
"SIM102", # Use a single if statement instead of nested if statements
"SIM108", # Use ternary operator {contents} instead of if-else-block
"SIM115", # Use context handler for opening files
diff --git a/requirements.txt b/requirements.txt
index c5b5e54046d..f2f26f9bb54 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,8 +4,9 @@
# Home Assistant Core
aiodns==3.2.0
-aiohttp==3.9.3
+aiohttp==3.9.4
aiohttp_cors==0.7.0
+aiohttp_session==2.12.0
aiohttp-fast-url-dispatcher==0.3.0
aiohttp-zlib-ng==0.3.1
astral==2.2
diff --git a/requirements_all.txt b/requirements_all.txt
index 773df97bfba..130ff6644c6 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -1081,7 +1081,7 @@ hole==0.8.0
holidays==0.46
# homeassistant.components.frontend
-home-assistant-frontend==20240404.1
+home-assistant-frontend==20240404.2
# homeassistant.components.conversation
home-assistant-intents==2024.4.3
@@ -1722,7 +1722,7 @@ pybbox==0.0.5-alpha
pyblackbird==0.6
# homeassistant.components.neato
-pybotvac==0.0.24
+pybotvac==0.0.25
# homeassistant.components.braviatv
pybravia==0.3.3
@@ -2294,7 +2294,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
-python-roborock==1.0.0
+python-roborock==2.0.0
# homeassistant.components.smarttub
python-smarttub==0.0.36
@@ -2857,7 +2857,7 @@ webmin-xmlrpc==0.0.2
webrtc-noise-gain==1.2.3
# homeassistant.components.whirlpool
-whirlpool-sixth-sense==0.18.7
+whirlpool-sixth-sense==0.18.8
# homeassistant.components.whois
whois==0.9.27
@@ -2935,7 +2935,7 @@ zamg==0.3.6
zengge==0.2
# homeassistant.components.zeroconf
-zeroconf==0.132.0
+zeroconf==0.132.2
# homeassistant.components.zeversolar
zeversolar==0.3.1
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 9428dcd42ca..d1b55f5c1ff 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -883,7 +883,7 @@ hole==0.8.0
holidays==0.46
# homeassistant.components.frontend
-home-assistant-frontend==20240404.1
+home-assistant-frontend==20240404.2
# homeassistant.components.conversation
home-assistant-intents==2024.4.3
@@ -1357,7 +1357,7 @@ pybalboa==1.0.1
pyblackbird==0.6
# homeassistant.components.neato
-pybotvac==0.0.24
+pybotvac==0.0.25
# homeassistant.components.braviatv
pybravia==0.3.3
@@ -1773,7 +1773,7 @@ python-qbittorrent==0.4.3
python-rabbitair==0.0.8
# homeassistant.components.roborock
-python-roborock==1.0.0
+python-roborock==2.0.0
# homeassistant.components.smarttub
python-smarttub==0.0.36
@@ -2207,7 +2207,7 @@ webmin-xmlrpc==0.0.2
webrtc-noise-gain==1.2.3
# homeassistant.components.whirlpool
-whirlpool-sixth-sense==0.18.7
+whirlpool-sixth-sense==0.18.8
# homeassistant.components.whois
whois==0.9.27
@@ -2273,7 +2273,7 @@ yt-dlp==2024.04.09
zamg==0.3.6
# homeassistant.components.zeroconf
-zeroconf==0.132.0
+zeroconf==0.132.2
# homeassistant.components.zeversolar
zeversolar==0.3.1
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index dacdb752a8d..46ade953da2 100644
--- a/requirements_test_pre_commit.txt
+++ b/requirements_test_pre_commit.txt
@@ -1,5 +1,5 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.2.6
-ruff==0.3.5
+ruff==0.3.7
yamllint==1.35.1
diff --git a/script/hassfest/coverage.py b/script/hassfest/coverage.py
index 264960a42e1..686a6697e49 100644
--- a/script/hassfest/coverage.py
+++ b/script/hassfest/coverage.py
@@ -20,24 +20,17 @@ DONT_IGNORE = (
"scene.py",
)
-PREFIX = """# Sorted by hassfest.
+CORE_PREFIX = """# Sorted by hassfest.
#
# To sort, run python3 -m script.hassfest -p coverage
[run]
source = homeassistant
omit =
- homeassistant/__main__.py
- homeassistant/helpers/signal.py
- homeassistant/scripts/__init__.py
- homeassistant/scripts/check_config.py
- homeassistant/scripts/ensure_config.py
- homeassistant/scripts/benchmark/__init__.py
- homeassistant/scripts/macos/__init__.py
-
- # omit pieces of code that rely on external devices being present
"""
-
+COMPONENTS_PREFIX = (
+ " # omit pieces of code that rely on external devices being present\n"
+)
SUFFIX = """[report]
# Regexes for lines to exclude from consideration
exclude_lines =
@@ -62,6 +55,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
coverage_path = config.root / ".coveragerc"
not_found: list[str] = []
+ unsorted: list[str] = []
checking = False
previous_line = ""
@@ -69,6 +63,10 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
for line in fp:
line = line.strip()
+ if line == COMPONENTS_PREFIX.strip():
+ previous_line = ""
+ continue
+
if not line or line.startswith("#"):
continue
@@ -92,27 +90,21 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
not_found.append(line)
continue
+ if line < previous_line:
+ unsorted.append(line)
+ previous_line = line
+
if not line.startswith("homeassistant/components/"):
continue
- integration_path = path.parent
- while len(integration_path.parts) > 3:
- integration_path = integration_path.parent
-
- integration = integrations[integration_path.name]
-
- # Ensure sorted
- if line < previous_line:
- integration.add_error(
- "coverage",
- f"{line} is unsorted in .coveragerc file",
- )
- previous_line = line
-
- # Ignore sub-directories for further checks
+ # Ignore sub-directories
if len(path.parts) > 4:
continue
+ integration_path = path.parent
+
+ integration = integrations[integration_path.name]
+
if (
path.parts[-1] == "*"
and Path(f"tests/components/{integration.domain}/__init__.py").exists()
@@ -132,6 +124,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
f"{check} must not be ignored by the .coveragerc file",
)
+ if unsorted:
+ config.add_error(
+ "coverage",
+ "Paths are unsorted in .coveragerc file. "
+ "Run python3 -m script.hassfest\n - "
+ f"{'\n - '.join(unsorted)}",
+ fixable=True,
+ )
+
if not_found:
raise RuntimeError(
f".coveragerc references files that don't exist: {', '.join(not_found)}."
@@ -141,23 +142,31 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
def generate(integrations: dict[str, Integration], config: Config) -> None:
"""Sort coverage."""
coverage_path = config.root / ".coveragerc"
- lines = []
- start = False
+ core = []
+ components = []
+ section = "header"
with coverage_path.open("rt") as fp:
for line in fp:
- if (
- not start
- and line
- == " # omit pieces of code that rely on external devices being present\n"
- ):
- start = True
- elif line == "[report]\n":
+ if line == "[report]\n":
break
- elif start and line != "\n":
- lines.append(line)
- content = f"{PREFIX}{"".join(sorted(lines))}\n\n{SUFFIX}"
+ if section != "core" and line == "omit =\n":
+ section = "core"
+ elif section != "components" and line == COMPONENTS_PREFIX:
+ section = "components"
+ elif section == "core" and line != "\n":
+ core.append(line)
+ elif section == "components" and line != "\n":
+ components.append(line)
+
+ assert core, "core should be a non-empty list"
+ assert components, "components should be a non-empty list"
+ content = (
+ f"{CORE_PREFIX}{"".join(sorted(core))}\n"
+ f"{COMPONENTS_PREFIX}{"".join(sorted(components))}\n"
+ f"\n{SUFFIX}"
+ )
with coverage_path.open("w") as fp:
fp.write(content)
diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py
index c02ebd8de2e..76fe47837e4 100644
--- a/script/hassfest/mypy_config.py
+++ b/script/hassfest/mypy_config.py
@@ -32,7 +32,7 @@ HEADER: Final = """
GENERAL_SETTINGS: Final[dict[str, str]] = {
"python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]),
- "plugins": ", ".join(["pydantic.mypy"]),
+ "plugins": "pydantic.mypy",
"show_error_codes": "true",
"follow_imports": "silent",
# Enable some checks globally.
@@ -43,14 +43,14 @@ GENERAL_SETTINGS: Final[dict[str, str]] = {
"warn_redundant_casts": "true",
"warn_unused_configs": "true",
"warn_unused_ignores": "true",
- "enable_error_code": ", ".join(
+ "enable_error_code": ", ".join( # noqa: FLY002
[
"ignore-without-code",
"redundant-self",
"truthy-iterable",
]
),
- "disable_error_code": ", ".join(
+ "disable_error_code": ", ".join( # noqa: FLY002
[
"annotation-unchecked",
"import-not-found",
diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py
index 00cdc5ddbee..fb2d4e0a504 100644
--- a/tests/components/alarm_control_panel/test_device_trigger.py
+++ b/tests/components/alarm_control_panel/test_device_trigger.py
@@ -497,15 +497,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -564,15 +561,12 @@ async def test_if_fires_on_state_change_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py
index 0ac2e5973fe..5443d48452f 100644
--- a/tests/components/api/test_init.py
+++ b/tests/components/api/test_init.py
@@ -306,7 +306,7 @@ async def test_api_get_services(
for serv_domain in data:
local = local_services.pop(serv_domain["domain"])
- assert serv_domain["services"] == local
+ assert serv_domain["services"].keys() == local.keys()
async def test_api_call_service_no_data(
diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py
index 7805f3ea151..5b3fc2a723e 100644
--- a/tests/components/automation/test_init.py
+++ b/tests/components/automation/test_init.py
@@ -2651,3 +2651,83 @@ def test_deprecated_constants(
import_and_test_deprecated_constant(
caplog, automation, constant_name, replacement.__name__, replacement, "2025.1"
)
+
+
+async def test_automation_turns_off_other_automation(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Test an automation that turns off another automation."""
+ hass.set_state(CoreState.not_running)
+ calls = async_mock_service(hass, "persistent_notification", "create")
+ hass.states.async_set("binary_sensor.presence", "on")
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "state",
+ "entity_id": "binary_sensor.presence",
+ "from": "on",
+ },
+ "action": {
+ "service": "automation.turn_off",
+ "target": {
+ "entity_id": "automation.automation_1",
+ },
+ "data": {
+ "stop_actions": True,
+ },
+ },
+ "id": "automation_0",
+ "mode": "single",
+ },
+ {
+ "trigger": {
+ "platform": "state",
+ "entity_id": "binary_sensor.presence",
+ "from": "on",
+ "for": {
+ "hours": 0,
+ "minutes": 0,
+ "seconds": 5,
+ },
+ },
+ "action": {
+ "service": "persistent_notification.create",
+ "metadata": {},
+ "data": {
+ "message": "Test race",
+ },
+ },
+ "id": "automation_1",
+ "mode": "single",
+ },
+ ]
+ },
+ )
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ hass.states.async_set("binary_sensor.presence", "off")
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5))
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ await hass.services.async_call(
+ "automation",
+ "turn_on",
+ {"entity_id": "automation.automation_1"},
+ blocking=True,
+ )
+ hass.states.async_set("binary_sensor.presence", "off")
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5))
+ await hass.async_block_till_done()
+ assert len(calls) == 0
diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py
index fce022572c3..7f679773f93 100644
--- a/tests/components/balboa/conftest.py
+++ b/tests/components/balboa/conftest.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable, Generator
from unittest.mock import AsyncMock, MagicMock, patch
-from pybalboa.enums import HeatMode
+from pybalboa.enums import HeatMode, LowHighRange
import pytest
from homeassistant.core import HomeAssistant
@@ -60,5 +60,6 @@ def client_fixture() -> Generator[MagicMock, None, None]:
client.heat_state = 2
client.lights = []
client.pumps = []
+ client.temperature_range.state = LowHighRange.LOW
yield client
diff --git a/tests/components/balboa/test_select.py b/tests/components/balboa/test_select.py
new file mode 100644
index 00000000000..bd79f024817
--- /dev/null
+++ b/tests/components/balboa/test_select.py
@@ -0,0 +1,85 @@
+"""Tests of the select entity of the balboa integration."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, call
+
+from pybalboa import SpaControl
+from pybalboa.enums import LowHighRange
+import pytest
+
+from homeassistant.components.select import (
+ ATTR_OPTION,
+ DOMAIN as SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+)
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.core import HomeAssistant
+
+from . import client_update, init_integration
+
+ENTITY_SELECT = "select.fakespa_temperature_range"
+
+
+@pytest.fixture
+def mock_select(client: MagicMock):
+ """Return a mock switch."""
+ select = MagicMock(SpaControl)
+
+ async def set_state(state: LowHighRange):
+ select.state = state # mock the spacontrol state
+
+ select.client = client
+ select.state = LowHighRange.LOW
+ select.set_state = set_state
+ client.temperature_range = select
+ return select
+
+
+async def test_select(hass: HomeAssistant, client: MagicMock, mock_select) -> None:
+ """Test spa temperature range select."""
+ await init_integration(hass)
+
+ # check if the initial state is off
+ state = hass.states.get(ENTITY_SELECT)
+ assert state.state == LowHighRange.LOW.name.lower()
+
+ # test high state
+ await _select_option_and_wait(hass, ENTITY_SELECT, LowHighRange.HIGH.name.lower())
+ assert client.set_temperature_range.call_count == 1
+ assert client.set_temperature_range.call_args == call(LowHighRange.HIGH)
+
+ # test back to low state
+ await _select_option_and_wait(hass, ENTITY_SELECT, LowHighRange.LOW.name.lower())
+ assert client.set_temperature_range.call_count == 2 # total call count
+ assert client.set_temperature_range.call_args == call(LowHighRange.LOW)
+
+
+async def test_selected_option(
+ hass: HomeAssistant, client: MagicMock, mock_select
+) -> None:
+ """Test spa temperature range selected option."""
+
+ await init_integration(hass)
+
+ # ensure initial low state
+ state = hass.states.get(ENTITY_SELECT)
+ assert state.state == LowHighRange.LOW.name.lower()
+
+ # ensure high state
+ mock_select.state = LowHighRange.HIGH
+ state = await client_update(hass, client, ENTITY_SELECT)
+ assert state.state == LowHighRange.HIGH.name.lower()
+
+
+async def _select_option_and_wait(hass: HomeAssistant | None, entity, option):
+ await hass.services.async_call(
+ SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+ {
+ ATTR_ENTITY_ID: entity,
+ ATTR_OPTION: option,
+ },
+ blocking=True,
+ )
+ await hass.async_block_till_done()
diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py
index 93689b4f233..6837c882a01 100644
--- a/tests/components/binary_sensor/test_device_condition.py
+++ b/tests/components/binary_sensor/test_device_condition.py
@@ -275,8 +275,10 @@ async def test_if_state(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_on {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -294,8 +296,10 @@ async def test_if_state(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_off {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -359,8 +363,10 @@ async def test_if_state_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_on {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -421,9 +427,9 @@ async def test_if_fires_on_for_condition(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- ("platform", "event.event_type")
+ "some": (
+ "is_off {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
)
},
},
diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py
index 76dcdb33993..dd55682fc8d 100644
--- a/tests/components/binary_sensor/test_device_trigger.py
+++ b/tests/components/binary_sensor/test_device_trigger.py
@@ -277,15 +277,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "bat_low {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "bat_low {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -301,15 +298,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "not_bat_low {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "not_bat_low {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -379,15 +373,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -453,15 +444,12 @@ async def test_if_fires_on_state_change_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py
index afd39fe6d8e..8e2f794f1e0 100644
--- a/tests/components/cover/test_device_trigger.py
+++ b/tests/components/cover/test_device_trigger.py
@@ -625,15 +625,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py
index e70f0144e6a..a3b982ab70e 100644
--- a/tests/components/demo/test_vacuum.py
+++ b/tests/components/demo/test_vacuum.py
@@ -219,7 +219,7 @@ async def test_services(hass: HomeAssistant) -> None:
async def test_set_fan_speed(hass: HomeAssistant) -> None:
"""Test vacuum service to set the fan speed."""
- group_vacuums = ",".join([ENTITY_VACUUM_COMPLETE, ENTITY_VACUUM_MOST])
+ group_vacuums = f"{ENTITY_VACUUM_COMPLETE},{ENTITY_VACUUM_MOST}"
old_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE)
old_state_most = hass.states.get(ENTITY_VACUUM_MOST)
@@ -239,7 +239,7 @@ async def test_set_fan_speed(hass: HomeAssistant) -> None:
async def test_send_command(hass: HomeAssistant) -> None:
"""Test vacuum service to send a command."""
- group_vacuums = ",".join([ENTITY_VACUUM_COMPLETE])
+ group_vacuums = f"{ENTITY_VACUUM_COMPLETE}"
old_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE)
await common.async_send_command(
diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py
index ac5e490b738..4526a9d9b67 100644
--- a/tests/components/device_automation/test_init.py
+++ b/tests/components/device_automation/test_init.py
@@ -1446,8 +1446,10 @@ async def test_automation_with_sub_condition(
"action": {
"service": "test.automation",
"data_template": {
- "some": "and {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "and {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -1477,8 +1479,10 @@ async def test_automation_with_sub_condition(
"action": {
"service": "test.automation",
"data_template": {
- "some": "or {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "or {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py
index 44a29d4a9ba..a8850bf50b9 100644
--- a/tests/components/device_automation/test_toggle_entity.py
+++ b/tests/components/device_automation/test_toggle_entity.py
@@ -64,15 +64,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_on {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -88,15 +85,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -112,15 +106,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on_or_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_on_or_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -187,15 +178,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py
index c121569184f..a217a5d89ec 100644
--- a/tests/components/fan/test_device_trigger.py
+++ b/tests/components/fan/test_device_trigger.py
@@ -385,15 +385,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/file_upload/test_init.py b/tests/components/file_upload/test_init.py
index 1ef238cafd0..fa77f6e55f5 100644
--- a/tests/components/file_upload/test_init.py
+++ b/tests/components/file_upload/test_init.py
@@ -90,9 +90,9 @@ async def test_upload_large_file(
file_upload.TEMP_DIR_NAME + f"-{getrandbits(10):03x}",
),
patch(
- # Patch one megabyte to 8 bytes to prevent having to use big files in tests
+ # Patch one megabyte to 50 bytes to prevent having to use big files in tests
"homeassistant.components.file_upload.ONE_MEGABYTE",
- 8,
+ 50,
),
):
res = await client.post("/api/file_upload", data={"file": large_file_io})
@@ -152,9 +152,9 @@ async def test_upload_large_file_fails(
file_upload.TEMP_DIR_NAME + f"-{getrandbits(10):03x}",
),
patch(
- # Patch one megabyte to 8 bytes to prevent having to use big files in tests
+ # Patch one megabyte to 50 bytes to prevent having to use big files in tests
"homeassistant.components.file_upload.ONE_MEGABYTE",
- 8,
+ 50,
),
patch(
"homeassistant.components.file_upload.Path.open", return_value=_mock_open()
diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py
index a3eeec10fa5..018d1c43b70 100644
--- a/tests/components/flux/test_switch.py
+++ b/tests/components/flux/test_switch.py
@@ -1115,7 +1115,7 @@ async def test_flux_with_mired(
hass: HomeAssistant,
mock_light_entities: list[MockLight],
) -> None:
- """Test the flux switch´s mode mired."""
+ """Test the flux switch's mode mired."""
setup_test_component_platform(hass, light.DOMAIN, mock_light_entities)
assert await async_setup_component(
@@ -1176,7 +1176,7 @@ async def test_flux_with_rgb(
hass: HomeAssistant,
mock_light_entities: list[MockLight],
) -> None:
- """Test the flux switch´s mode rgb."""
+ """Test the flux switch's mode rgb."""
setup_test_component_platform(hass, light.DOMAIN, mock_light_entities)
assert await async_setup_component(
diff --git a/tests/components/fyta/conftest.py b/tests/components/fyta/conftest.py
index e35012a02e8..efebf9827b9 100644
--- a/tests/components/fyta/conftest.py
+++ b/tests/components/fyta/conftest.py
@@ -5,8 +5,6 @@ from unittest.mock import AsyncMock, patch
import pytest
-from .test_config_flow import ACCESS_TOKEN, EXPIRATION
-
@pytest.fixture
def mock_fyta():
@@ -17,10 +15,7 @@ def mock_fyta():
"homeassistant.components.fyta.config_flow.FytaConnector",
return_value=mock_fyta_api,
) as mock_fyta_api:
- mock_fyta_api.return_value.login.return_value = {
- "access_token": ACCESS_TOKEN,
- "expiration": EXPIRATION,
- }
+ mock_fyta_api.return_value.login.return_value = {}
yield mock_fyta_api
diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py
index 60e6fc76c5b..6aad6295819 100644
--- a/tests/components/fyta/test_config_flow.py
+++ b/tests/components/fyta/test_config_flow.py
@@ -1,6 +1,5 @@
"""Test the fyta config flow."""
-from datetime import datetime
from unittest.mock import AsyncMock
from fyta_cli.fyta_exceptions import (
@@ -20,8 +19,6 @@ from tests.common import MockConfigEntry
USERNAME = "fyta_user"
PASSWORD = "fyta_pass"
-ACCESS_TOKEN = "123xyz"
-EXPIRATION = datetime.now()
async def test_user_flow(
@@ -121,3 +118,65 @@ async def test_duplicate_entry(hass: HomeAssistant, mock_fyta: AsyncMock) -> Non
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
+
+
+@pytest.mark.parametrize(
+ ("exception", "error"),
+ [
+ (FytaConnectionError, {"base": "cannot_connect"}),
+ (FytaAuthentificationError, {"base": "invalid_auth"}),
+ (FytaPasswordError, {"base": "invalid_auth", CONF_PASSWORD: "password_error"}),
+ (Exception, {"base": "unknown"}),
+ ],
+)
+async def test_reauth(
+ hass: HomeAssistant,
+ exception: Exception,
+ error: dict[str, str],
+ mock_fyta: AsyncMock,
+ mock_setup_entry,
+) -> None:
+ """Test reauth-flow works."""
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ title=USERNAME,
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_REAUTH, "entry_id": entry.entry_id},
+ data=entry.data,
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "reauth_confirm"
+
+ mock_fyta.return_value.login.side_effect = exception
+
+ # tests with connection error
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
+ )
+ await hass.async_block_till_done()
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "reauth_confirm"
+ assert result["errors"] == error
+
+ mock_fyta.return_value.login.side_effect = None
+
+ # tests with all information provided
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_USERNAME: "other_username", CONF_PASSWORD: "other_password"},
+ )
+ await hass.async_block_till_done()
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reauth_successful"
+ assert entry.data[CONF_USERNAME] == "other_username"
+ assert entry.data[CONF_PASSWORD] == "other_password"
+
+ assert len(mock_setup_entry.mock_calls) == 1
diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py
index 85461d60aac..b8045ad495c 100644
--- a/tests/components/geo_location/test_trigger.py
+++ b/tests/components/geo_location/test_trigger.py
@@ -72,16 +72,13 @@ async def test_if_fires_on_zone_enter(hass: HomeAssistant, calls) -> None:
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "zone.name",
- "id",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.zone.name }}"
+ " - {{ trigger.id }}"
)
},
},
@@ -285,15 +282,12 @@ async def test_if_fires_on_zone_appear(hass: HomeAssistant, calls) -> None:
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "zone.name",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.zone.name }}"
)
},
},
@@ -334,15 +328,12 @@ async def test_if_fires_on_zone_appear_2(hass: HomeAssistant, calls) -> None:
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "zone.name",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.zone.name }}"
)
},
},
@@ -399,15 +390,12 @@ async def test_if_fires_on_zone_disappear(hass: HomeAssistant, calls) -> None:
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "zone.name",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.zone.name }}"
)
},
},
diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py
index 5709e648508..2f9afdf5aa5 100644
--- a/tests/components/group/test_notify.py
+++ b/tests/components/group/test_notify.py
@@ -1,31 +1,91 @@
"""The tests for the notify.group platform."""
-from unittest.mock import MagicMock, patch
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any
+from unittest.mock import MagicMock, call, patch
from homeassistant import config as hass_config
from homeassistant.components import notify
-import homeassistant.components.demo.notify as demo
from homeassistant.components.group import SERVICE_RELOAD
import homeassistant.components.group.notify as group
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.setup import async_setup_component
-from tests.common import get_fixture_path
+from tests.common import MockPlatform, get_fixture_path, mock_platform
-async def test_send_message_with_data(hass: HomeAssistant) -> None:
+class MockNotifyPlatform(MockPlatform):
+ """Help to set up a legacy test notify platform."""
+
+ def __init__(self, async_get_service: Any) -> None:
+ """Initialize platform."""
+ super().__init__()
+ self.async_get_service = async_get_service
+
+
+def mock_notify_platform(
+ hass: HomeAssistant,
+ tmp_path: Path,
+ async_get_service: Any = None,
+):
+ """Specialize the mock platform for legacy notify service."""
+ loaded_platform = MockNotifyPlatform(async_get_service)
+ mock_platform(hass, "test.notify", loaded_platform)
+
+ return loaded_platform
+
+
+async def help_setup_notify(
+ hass: HomeAssistant,
+ tmp_path: Path,
+ targets: dict[str, None] | None = None,
+ group_setup: list[dict[str, None]] | None = None,
+) -> MagicMock:
+ """Help set up a platform notify service."""
+ send_message_mock = MagicMock()
+
+ class _TestNotifyService(notify.BaseNotificationService):
+ def __init__(self, targets: dict[str, None] | None) -> None:
+ """Initialize service."""
+ self._targets = targets
+ super().__init__()
+
+ @property
+ def targets(self) -> Mapping[str, Any] | None:
+ """Return a dictionary of registered targets."""
+ return self._targets
+
+ def send_message(self, message: str, **kwargs: Any) -> None:
+ """Send a message."""
+ send_message_mock(message, kwargs)
+
+ async def async_get_service(
+ hass: HomeAssistant,
+ config: ConfigType,
+ discovery_info: DiscoveryInfoType | None = None,
+ ) -> notify.BaseNotificationService:
+ """Get notify service for mocked platform."""
+ return _TestNotifyService(targets)
+
+ # Mock platform with service
+ mock_notify_platform(hass, tmp_path, async_get_service=async_get_service)
+ # Setup the platform
+ items: list[dict[str, Any]] = [{"platform": "test"}]
+ items.extend(group_setup or [])
+ await async_setup_component(hass, "notify", {"notify": items})
+ await hass.async_block_till_done()
+
+ # Return mock for assertion service calls
+ return send_message_mock
+
+
+async def test_send_message_with_data(hass: HomeAssistant, tmp_path: Path) -> None:
"""Test sending a message with to a notify group."""
- service1 = demo.DemoNotificationService(hass)
- service2 = demo.DemoNotificationService(hass)
-
- service1.send_message = MagicMock(autospec=True)
- service2.send_message = MagicMock(autospec=True)
-
- def mock_get_service(hass, config, discovery_info=None):
- if config["name"] == "demo1":
- return service1
- return service2
-
+ send_message_mock = await help_setup_notify(
+ hass, tmp_path, {"service1": 1, "service2": 2}
+ )
assert await async_setup_component(
hass,
"group",
@@ -33,26 +93,13 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
- with patch.object(demo, "get_service", mock_get_service):
- await async_setup_component(
- hass,
- notify.DOMAIN,
- {
- "notify": [
- {"name": "demo1", "platform": "demo"},
- {"name": "demo2", "platform": "demo"},
- ]
- },
- )
- await hass.async_block_till_done()
-
service = await group.async_get_service(
hass,
{
"services": [
- {"service": "demo1"},
+ {"service": "test_service1"},
{
- "service": "demo2",
+ "service": "test_service2",
"data": {
"target": "unnamed device",
"data": {"test": "message", "default": "default"},
@@ -62,26 +109,35 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None:
},
)
- """Test sending a message to a notify group."""
+ # Test sending a message to a notify group.
await service.async_send_message(
"Hello", title="Test notification", data={"hello": "world"}
)
await hass.async_block_till_done()
+ send_message_mock.assert_has_calls(
+ [
+ call(
+ "Hello",
+ {
+ "title": "Test notification",
+ "target": [1],
+ "data": {"hello": "world"},
+ },
+ ),
+ call(
+ "Hello",
+ {
+ "title": "Test notification",
+ "target": [2],
+ "data": {"hello": "world", "test": "message", "default": "default"},
+ },
+ ),
+ ]
+ )
+ send_message_mock.reset_mock()
- assert service1.send_message.mock_calls[0][1][0] == "Hello"
- assert service1.send_message.mock_calls[0][2] == {
- "title": "Test notification",
- "data": {"hello": "world"},
- }
- assert service2.send_message.mock_calls[0][1][0] == "Hello"
- assert service2.send_message.mock_calls[0][2] == {
- "target": ["unnamed device"],
- "title": "Test notification",
- "data": {"hello": "world", "test": "message", "default": "default"},
- }
-
- """Test sending a message which overrides service defaults to a notify group."""
+ # Test sending a message which overrides service defaults to a notify group
await service.async_send_message(
"Hello",
title="Test notification",
@@ -90,22 +146,34 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
- assert service1.send_message.mock_calls[1][1][0] == "Hello"
- assert service1.send_message.mock_calls[1][2] == {
- "title": "Test notification",
- "data": {"hello": "world", "default": "override"},
- }
- assert service2.send_message.mock_calls[1][1][0] == "Hello"
- assert service2.send_message.mock_calls[1][2] == {
- "target": ["unnamed device"],
- "title": "Test notification",
- "data": {"hello": "world", "test": "message", "default": "override"},
- }
+ send_message_mock.assert_has_calls(
+ [
+ call(
+ "Hello",
+ {
+ "title": "Test notification",
+ "target": [1],
+ "data": {"hello": "world", "default": "override"},
+ },
+ ),
+ call(
+ "Hello",
+ {
+ "title": "Test notification",
+ "target": [2],
+ "data": {
+ "hello": "world",
+ "test": "message",
+ "default": "override",
+ },
+ },
+ ),
+ ]
+ )
-async def test_reload_notify(hass: HomeAssistant) -> None:
+async def test_reload_notify(hass: HomeAssistant, tmp_path: Path) -> None:
"""Verify we can reload the notify service."""
-
assert await async_setup_component(
hass,
"group",
@@ -113,25 +181,21 @@ async def test_reload_notify(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
- assert await async_setup_component(
+ await help_setup_notify(
hass,
- notify.DOMAIN,
- {
- notify.DOMAIN: [
- {"name": "demo1", "platform": "demo"},
- {"name": "demo2", "platform": "demo"},
- {
- "name": "group_notify",
- "platform": "group",
- "services": [{"service": "demo1"}],
- },
- ]
- },
+ tmp_path,
+ {"service1": 1, "service2": 2},
+ [
+ {
+ "name": "group_notify",
+ "platform": "group",
+ "services": [{"service": "test_service1"}],
+ }
+ ],
)
- await hass.async_block_till_done()
- assert hass.services.has_service(notify.DOMAIN, "demo1")
- assert hass.services.has_service(notify.DOMAIN, "demo2")
+ assert hass.services.has_service(notify.DOMAIN, "test_service1")
+ assert hass.services.has_service(notify.DOMAIN, "test_service2")
assert hass.services.has_service(notify.DOMAIN, "group_notify")
yaml_path = get_fixture_path("configuration.yaml", "group")
@@ -145,7 +209,7 @@ async def test_reload_notify(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
- assert hass.services.has_service(notify.DOMAIN, "demo1")
- assert hass.services.has_service(notify.DOMAIN, "demo2")
+ assert hass.services.has_service(notify.DOMAIN, "test_service1")
+ assert hass.services.has_service(notify.DOMAIN, "test_service2")
assert not hass.services.has_service(notify.DOMAIN, "group_notify")
assert hass.services.has_service(notify.DOMAIN, "new_group_notify")
diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py
index d387968da46..2dffba74fef 100644
--- a/tests/components/hassio/test_repairs.py
+++ b/tests/components/hassio/test_repairs.py
@@ -674,3 +674,116 @@ async def test_supervisor_issue_docker_config_repair_flow(
str(aioclient_mock.mock_calls[-1][1])
== "http://127.0.0.1/resolution/suggestion/1235"
)
+
+
+async def test_supervisor_issue_repair_flow_multiple_data_disks(
+ hass: HomeAssistant,
+ aioclient_mock: AiohttpClientMocker,
+ hass_client: ClientSessionGenerator,
+ issue_registry: ir.IssueRegistry,
+ all_setup_requests,
+) -> None:
+ """Test fix flow for multiple data disks supervisor issue."""
+ mock_resolution_info(
+ aioclient_mock,
+ issues=[
+ {
+ "uuid": "1234",
+ "type": "multiple_data_disks",
+ "context": "system",
+ "reference": "/dev/sda1",
+ "suggestions": [
+ {
+ "uuid": "1235",
+ "type": "rename_data_disk",
+ "context": "system",
+ "reference": "/dev/sda1",
+ },
+ {
+ "uuid": "1236",
+ "type": "adopt_data_disk",
+ "context": "system",
+ "reference": "/dev/sda1",
+ },
+ ],
+ },
+ ],
+ )
+
+ assert await async_setup_component(hass, "hassio", {})
+
+ repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234")
+ 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": "menu",
+ "flow_id": flow_id,
+ "handler": "hassio",
+ "step_id": "fix_menu",
+ "data_schema": [
+ {
+ "type": "select",
+ "options": [
+ ["system_rename_data_disk", "system_rename_data_disk"],
+ ["system_adopt_data_disk", "system_adopt_data_disk"],
+ ],
+ "name": "next_step_id",
+ }
+ ],
+ "menu_options": ["system_rename_data_disk", "system_adopt_data_disk"],
+ "description_placeholders": {"reference": "/dev/sda1"},
+ }
+
+ resp = await client.post(
+ f"/api/repairs/issues/fix/{flow_id}",
+ json={"next_step_id": "system_adopt_data_disk"},
+ )
+
+ 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_adopt_data_disk",
+ "data_schema": [],
+ "errors": None,
+ "description_placeholders": {"reference": "/dev/sda1"},
+ "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="1234")
+
+ assert aioclient_mock.mock_calls[-1][0] == "post"
+ assert (
+ str(aioclient_mock.mock_calls[-1][1])
+ == "http://127.0.0.1/resolution/suggestion/1236"
+ )
diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py
index cf2e1938228..2e2dca5b57a 100644
--- a/tests/components/homeassistant/triggers/test_numeric_state.py
+++ b/tests/components/homeassistant/triggers/test_numeric_state.py
@@ -980,16 +980,13 @@ async def test_template_string(hass: HomeAssistant, calls, below) -> None:
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "below",
- "above",
- "from_state.state",
- "to_state.state",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.below }}"
+ " - {{ trigger.above }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
)
},
},
@@ -1346,9 +1343,10 @@ async def test_wait_template_with_trigger(hass: HomeAssistant, calls, above) ->
{
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- ("platform", "entity_id", "to_state.state")
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.to_state.state }}"
)
},
},
diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py
index aaf228c06f8..597ef0ab1a5 100644
--- a/tests/components/homeassistant/triggers/test_state.py
+++ b/tests/components/homeassistant/triggers/test_state.py
@@ -55,16 +55,13 @@ async def test_if_fires_on_entity_change(hass: HomeAssistant, calls) -> None:
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- "id",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
+ " - {{ trigger.id }}"
)
},
},
@@ -114,16 +111,13 @@ async def test_if_fires_on_entity_change_uuid(
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- "id",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
+ " - {{ trigger.id }}"
)
},
},
@@ -1079,14 +1073,11 @@ async def test_wait_template_with_trigger(hass: HomeAssistant, calls) -> None:
{
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
)
},
},
diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py
index 8c45080c786..95bf2530b2d 100644
--- a/tests/components/homekit_controller/common.py
+++ b/tests/components/homekit_controller/common.py
@@ -306,7 +306,7 @@ async def setup_test_component(
config_entry, pairing = await setup_test_accessories(hass, [accessory], connection)
entity = "testdevice" if suffix is None else f"testdevice_{suffix}"
- return Helper(hass, ".".join((domain, entity)), pairing, accessory, config_entry)
+ return Helper(hass, f"{domain}.{entity}", pairing, accessory, config_entry)
async def assert_devices_and_entities_created(
diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py
index de6f323bc8a..f0f87e58173 100644
--- a/tests/components/http/test_auth.py
+++ b/tests/components/http/test_auth.py
@@ -1,22 +1,28 @@
"""The tests for the Home Assistant HTTP component."""
+from collections.abc import Awaitable, Callable
from datetime import timedelta
from http import HTTPStatus
from ipaddress import ip_network
+import logging
from unittest.mock import Mock, patch
-from aiohttp import BasicAuth, web
+from aiohttp import BasicAuth, ServerDisconnectedError, web
+from aiohttp.test_utils import TestClient
from aiohttp.web_exceptions import HTTPUnauthorized
+from aiohttp_session import get_session
import jwt
import pytest
import yarl
+from yarl import URL
from homeassistant.auth.const import GROUP_ID_READ_ONLY
-from homeassistant.auth.models import User
+from homeassistant.auth.models import RefreshToken, User
from homeassistant.auth.providers import trusted_networks
from homeassistant.auth.providers.legacy_api_password import (
LegacyApiPasswordAuthProvider,
)
+from homeassistant.auth.session import SESSION_ID, TEMP_TIMEOUT
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_HASS
from homeassistant.components.http.auth import (
@@ -24,11 +30,12 @@ from homeassistant.components.http.auth import (
DATA_SIGN_SECRET,
SIGN_QUERY_PARAM,
STORAGE_KEY,
+ STRICT_CONNECTION_STATIC_PAGE,
async_setup_auth,
async_sign_path,
async_user_not_allowed_do_auth,
)
-from homeassistant.components.http.const import KEY_AUTHENTICATED
+from homeassistant.components.http.const import KEY_AUTHENTICATED, StrictConnectionMode
from homeassistant.components.http.forwarded import async_setup_forwarded
from homeassistant.components.http.request_context import (
current_request,
@@ -36,13 +43,15 @@ from homeassistant.components.http.request_context import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.setup import async_setup_component
+from homeassistant.util.dt import utcnow
from . import HTTP_HEADER_HA_AUTH
-from tests.common import MockUser
+from tests.common import MockUser, async_fire_time_changed
from tests.test_util import mock_real_ip
from tests.typing import ClientSessionGenerator, WebSocketGenerator
+_LOGGER = logging.getLogger(__name__)
API_PASSWORD = "test-password"
# Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases
@@ -54,7 +63,13 @@ TRUSTED_NETWORKS = [
]
TRUSTED_ADDRESSES = ["100.64.0.1", "192.0.2.100", "FD01:DB8::1", "2001:DB8:ABCD::1"]
EXTERNAL_ADDRESSES = ["198.51.100.1", "2001:DB8:FA1::1"]
-UNTRUSTED_ADDRESSES = [*EXTERNAL_ADDRESSES, "127.0.0.1", "::1"]
+LOCALHOST_ADDRESSES = ["127.0.0.1", "::1"]
+UNTRUSTED_ADDRESSES = [*EXTERNAL_ADDRESSES, *LOCALHOST_ADDRESSES]
+PRIVATE_ADDRESSES = [
+ "192.168.10.10",
+ "172.16.4.20",
+ "10.100.50.5",
+]
async def mock_handler(request):
@@ -122,7 +137,7 @@ async def test_cant_access_with_password_in_header(
hass: HomeAssistant,
) -> None:
"""Test access with password in header."""
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
client = await aiohttp_client(app)
req = await client.get("/", headers={HTTP_HEADER_HA_AUTH: API_PASSWORD})
@@ -139,7 +154,7 @@ async def test_cant_access_with_password_in_query(
hass: HomeAssistant,
) -> None:
"""Test access with password in URL."""
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
client = await aiohttp_client(app)
resp = await client.get("/", params={"api_password": API_PASSWORD})
@@ -159,7 +174,7 @@ async def test_basic_auth_does_not_work(
legacy_auth: LegacyApiPasswordAuthProvider,
) -> None:
"""Test access with basic authentication."""
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
client = await aiohttp_client(app)
req = await client.get("/", auth=BasicAuth("homeassistant", API_PASSWORD))
@@ -183,7 +198,7 @@ async def test_cannot_access_with_trusted_ip(
hass_owner_user: MockUser,
) -> None:
"""Test access with an untrusted ip address."""
- await async_setup_auth(hass, app2)
+ await async_setup_auth(hass, app2, StrictConnectionMode.DISABLED)
set_mock_ip = mock_real_ip(app2)
client = await aiohttp_client(app2)
@@ -211,7 +226,7 @@ async def test_auth_active_access_with_access_token_in_header(
) -> None:
"""Test access with access token in header."""
token = hass_access_token
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
client = await aiohttp_client(app)
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
@@ -247,7 +262,7 @@ async def test_auth_active_access_with_trusted_ip(
hass_owner_user: MockUser,
) -> None:
"""Test access with an untrusted ip address."""
- await async_setup_auth(hass, app2)
+ await async_setup_auth(hass, app2, StrictConnectionMode.DISABLED)
set_mock_ip = mock_real_ip(app2)
client = await aiohttp_client(app2)
@@ -274,7 +289,7 @@ async def test_auth_legacy_support_api_password_cannot_access(
hass: HomeAssistant,
) -> None:
"""Test access using api_password if auth.support_legacy."""
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
client = await aiohttp_client(app)
req = await client.get("/", headers={HTTP_HEADER_HA_AUTH: API_PASSWORD})
@@ -296,7 +311,7 @@ async def test_auth_access_signed_path_with_refresh_token(
"""Test access with signed url."""
app.router.add_post("/", mock_handler)
app.router.add_get("/another_path", mock_handler)
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
client = await aiohttp_client(app)
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
@@ -341,7 +356,7 @@ async def test_auth_access_signed_path_with_query_param(
"""Test access with signed url and query params."""
app.router.add_post("/", mock_handler)
app.router.add_get("/another_path", mock_handler)
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
client = await aiohttp_client(app)
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
@@ -371,7 +386,7 @@ async def test_auth_access_signed_path_with_query_param_order(
"""Test access with signed url and query params different order."""
app.router.add_post("/", mock_handler)
app.router.add_get("/another_path", mock_handler)
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
client = await aiohttp_client(app)
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
@@ -412,7 +427,7 @@ async def test_auth_access_signed_path_with_query_param_safe_param(
"""Test access with signed url and changing a safe param."""
app.router.add_post("/", mock_handler)
app.router.add_get("/another_path", mock_handler)
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
client = await aiohttp_client(app)
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
@@ -451,7 +466,7 @@ async def test_auth_access_signed_path_with_query_param_tamper(
"""Test access with signed url and query params that have been tampered with."""
app.router.add_post("/", mock_handler)
app.router.add_get("/another_path", mock_handler)
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
client = await aiohttp_client(app)
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
@@ -520,7 +535,7 @@ async def test_auth_access_signed_path_with_http(
)
app.router.add_get("/hello", mock_handler)
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
client = await aiohttp_client(app)
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
@@ -544,7 +559,7 @@ async def test_auth_access_signed_path_with_content_user(
hass: HomeAssistant, app, aiohttp_client: ClientSessionGenerator
) -> None:
"""Test access signed url uses content user."""
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
signed_path = async_sign_path(hass, "/", timedelta(seconds=5))
signature = yarl.URL(signed_path).query["authSig"]
claims = jwt.decode(
@@ -564,7 +579,7 @@ async def test_local_only_user_rejected(
) -> None:
"""Test access with access token in header."""
token = hass_access_token
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
set_mock_ip = mock_real_ip(app)
client = await aiohttp_client(app)
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
@@ -630,7 +645,7 @@ async def test_create_user_once(hass: HomeAssistant) -> None:
"""Test that we reuse the user."""
cur_users = len(await hass.auth.async_get_users())
app = web.Application()
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
users = await hass.auth.async_get_users()
assert len(users) == cur_users + 1
@@ -642,7 +657,287 @@ async def test_create_user_once(hass: HomeAssistant) -> None:
assert len(user.refresh_tokens) == 1
assert user.system_generated
- await async_setup_auth(hass, app)
+ await async_setup_auth(hass, app, StrictConnectionMode.DISABLED)
# test it did not create a user
assert len(await hass.auth.async_get_users()) == cur_users + 1
+
+
+@pytest.fixture
+def app_strict_connection(hass):
+ """Fixture to set up a web.Application."""
+
+ async def handler(request):
+ """Return if request was authenticated."""
+ return web.json_response(data={"authenticated": request[KEY_AUTHENTICATED]})
+
+ app = web.Application()
+ app[KEY_HASS] = hass
+ app.router.add_get("/", handler)
+ async_setup_forwarded(app, True, [])
+ return app
+
+
+@pytest.mark.parametrize(
+ "strict_connection_mode", [e.value for e in StrictConnectionMode]
+)
+async def test_strict_connection_non_cloud_authenticated_requests(
+ hass: HomeAssistant,
+ app_strict_connection: web.Application,
+ aiohttp_client: ClientSessionGenerator,
+ hass_access_token: str,
+ strict_connection_mode: StrictConnectionMode,
+) -> None:
+ """Test authenticated requests with strict connection."""
+ token = hass_access_token
+ await async_setup_auth(hass, app_strict_connection, strict_connection_mode)
+ set_mock_ip = mock_real_ip(app_strict_connection)
+ client = await aiohttp_client(app_strict_connection)
+ refresh_token = hass.auth.async_validate_access_token(hass_access_token)
+ assert refresh_token
+ assert hass.auth.session._strict_connection_sessions == {}
+
+ signed_path = async_sign_path(
+ hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id
+ )
+
+ for remote_addr in (*LOCALHOST_ADDRESSES, *PRIVATE_ADDRESSES, *EXTERNAL_ADDRESSES):
+ set_mock_ip(remote_addr)
+
+ # authorized requests should work normally
+ req = await client.get("/", headers={"Authorization": f"Bearer {token}"})
+ assert req.status == HTTPStatus.OK
+ assert await req.json() == {"authenticated": True}
+ req = await client.get(signed_path)
+ assert req.status == HTTPStatus.OK
+ assert await req.json() == {"authenticated": True}
+
+
+@pytest.mark.parametrize(
+ "strict_connection_mode", [e.value for e in StrictConnectionMode]
+)
+async def test_strict_connection_non_cloud_local_unauthenticated_requests(
+ hass: HomeAssistant,
+ app_strict_connection: web.Application,
+ aiohttp_client: ClientSessionGenerator,
+ strict_connection_mode: StrictConnectionMode,
+) -> None:
+ """Test local unauthenticated requests with strict connection."""
+ await async_setup_auth(hass, app_strict_connection, strict_connection_mode)
+ set_mock_ip = mock_real_ip(app_strict_connection)
+ client = await aiohttp_client(app_strict_connection)
+ assert hass.auth.session._strict_connection_sessions == {}
+
+ for remote_addr in (*LOCALHOST_ADDRESSES, *PRIVATE_ADDRESSES):
+ set_mock_ip(remote_addr)
+ # local requests should work normally
+ req = await client.get("/")
+ assert req.status == HTTPStatus.OK
+ assert await req.json() == {"authenticated": False}
+
+
+def _add_set_cookie_endpoint(app: web.Application, refresh_token: RefreshToken) -> None:
+ """Add an endpoint to set a cookie."""
+
+ async def set_cookie(request: web.Request) -> web.Response:
+ hass = request.app[KEY_HASS]
+ # Clear all sessions
+ hass.auth.session._temp_sessions.clear()
+ hass.auth.session._strict_connection_sessions.clear()
+
+ if request.query["token"] == "refresh":
+ await hass.auth.session.async_create_session(request, refresh_token)
+ else:
+ await hass.auth.session.async_create_temp_unauthorized_session(request)
+ session = await get_session(request)
+ return web.Response(text=session[SESSION_ID])
+
+ app.router.add_get("/test/cookie", set_cookie)
+
+
+async def _test_strict_connection_non_cloud_enabled_setup(
+ hass: HomeAssistant,
+ app: web.Application,
+ aiohttp_client: ClientSessionGenerator,
+ hass_access_token: str,
+ strict_connection_mode: StrictConnectionMode,
+) -> tuple[TestClient, Callable[[str], None], RefreshToken]:
+ """Test external unauthenticated requests with strict connection non cloud enabled."""
+ refresh_token = hass.auth.async_validate_access_token(hass_access_token)
+ assert refresh_token
+ session = hass.auth.session
+ assert session._strict_connection_sessions == {}
+ assert session._temp_sessions == {}
+
+ _add_set_cookie_endpoint(app, refresh_token)
+ await async_setup_auth(hass, app, strict_connection_mode)
+ set_mock_ip = mock_real_ip(app)
+ client = await aiohttp_client(app)
+ return (client, set_mock_ip, refresh_token)
+
+
+async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests(
+ hass: HomeAssistant,
+ app: web.Application,
+ aiohttp_client: ClientSessionGenerator,
+ hass_access_token: str,
+ perform_unauthenticated_request: Callable[
+ [HomeAssistant, TestClient], Awaitable[None]
+ ],
+ strict_connection_mode: StrictConnectionMode,
+) -> None:
+ """Test external unauthenticated requests with strict connection non cloud enabled."""
+ client, set_mock_ip, _ = await _test_strict_connection_non_cloud_enabled_setup(
+ hass, app, aiohttp_client, hass_access_token, strict_connection_mode
+ )
+
+ for remote_addr in EXTERNAL_ADDRESSES:
+ set_mock_ip(remote_addr)
+ await perform_unauthenticated_request(hass, client)
+
+
+async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_refresh_token(
+ hass: HomeAssistant,
+ app: web.Application,
+ aiohttp_client: ClientSessionGenerator,
+ hass_access_token: str,
+ perform_unauthenticated_request: Callable[
+ [HomeAssistant, TestClient], Awaitable[None]
+ ],
+ strict_connection_mode: StrictConnectionMode,
+) -> None:
+ """Test external unauthenticated requests with strict connection non cloud enabled and refresh token cookie."""
+ (
+ client,
+ set_mock_ip,
+ refresh_token,
+ ) = await _test_strict_connection_non_cloud_enabled_setup(
+ hass, app, aiohttp_client, hass_access_token, strict_connection_mode
+ )
+ session = hass.auth.session
+
+ # set strict connection cookie with refresh token
+ set_mock_ip(LOCALHOST_ADDRESSES[0])
+ session_id = await (await client.get("/test/cookie?token=refresh")).text()
+ assert session._strict_connection_sessions == {session_id: refresh_token.id}
+ for remote_addr in EXTERNAL_ADDRESSES:
+ set_mock_ip(remote_addr)
+ req = await client.get("/")
+ assert req.status == HTTPStatus.OK
+ assert await req.json() == {"authenticated": False}
+
+ # Invalidate refresh token, which should also invalidate session
+ hass.auth.async_remove_refresh_token(refresh_token)
+ assert session._strict_connection_sessions == {}
+ for remote_addr in EXTERNAL_ADDRESSES:
+ set_mock_ip(remote_addr)
+ await perform_unauthenticated_request(hass, client)
+
+
+async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_temp_session(
+ hass: HomeAssistant,
+ app: web.Application,
+ aiohttp_client: ClientSessionGenerator,
+ hass_access_token: str,
+ perform_unauthenticated_request: Callable[
+ [HomeAssistant, TestClient], Awaitable[None]
+ ],
+ strict_connection_mode: StrictConnectionMode,
+) -> None:
+ """Test external unauthenticated requests with strict connection non cloud enabled and temp cookie."""
+ client, set_mock_ip, _ = await _test_strict_connection_non_cloud_enabled_setup(
+ hass, app, aiohttp_client, hass_access_token, strict_connection_mode
+ )
+ session = hass.auth.session
+
+ # set strict connection cookie with temp session
+ assert session._temp_sessions == {}
+ set_mock_ip(LOCALHOST_ADDRESSES[0])
+ session_id = await (await client.get("/test/cookie?token=temp")).text()
+ assert client.session.cookie_jar.filter_cookies(URL("http://127.0.0.1"))
+ assert session_id in session._temp_sessions
+ for remote_addr in EXTERNAL_ADDRESSES:
+ set_mock_ip(remote_addr)
+ resp = await client.get("/")
+ assert resp.status == HTTPStatus.OK
+ assert await resp.json() == {"authenticated": False}
+
+ async_fire_time_changed(hass, utcnow() + TEMP_TIMEOUT + timedelta(minutes=1))
+ await hass.async_block_till_done(wait_background_tasks=True)
+
+ assert session._temp_sessions == {}
+ for remote_addr in EXTERNAL_ADDRESSES:
+ set_mock_ip(remote_addr)
+ await perform_unauthenticated_request(hass, client)
+
+
+async def _drop_connection_unauthorized_request(
+ _: HomeAssistant, client: TestClient
+) -> None:
+ with pytest.raises(ServerDisconnectedError):
+ # unauthorized requests should raise ServerDisconnectedError
+ await client.get("/")
+
+
+async def _static_page_unauthorized_request(
+ hass: HomeAssistant, client: TestClient
+) -> None:
+ req = await client.get("/")
+ assert req.status == HTTPStatus.IM_A_TEAPOT
+
+ def read_static_page() -> str:
+ with open(STRICT_CONNECTION_STATIC_PAGE, encoding="utf-8") as file:
+ return file.read()
+
+ assert await req.text() == await hass.async_add_executor_job(read_static_page)
+
+
+@pytest.mark.parametrize(
+ "test_func",
+ [
+ _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests,
+ _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_refresh_token,
+ _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_temp_session,
+ ],
+ ids=[
+ "no cookie",
+ "refresh token cookie",
+ "temp session cookie",
+ ],
+)
+@pytest.mark.parametrize(
+ ("strict_connection_mode", "request_func"),
+ [
+ (StrictConnectionMode.DROP_CONNECTION, _drop_connection_unauthorized_request),
+ (StrictConnectionMode.STATIC_PAGE, _static_page_unauthorized_request),
+ ],
+ ids=["drop connection", "static page"],
+)
+async def test_strict_connection_non_cloud_external_unauthenticated_requests(
+ hass: HomeAssistant,
+ app_strict_connection: web.Application,
+ aiohttp_client: ClientSessionGenerator,
+ hass_access_token: str,
+ test_func: Callable[
+ [
+ HomeAssistant,
+ web.Application,
+ ClientSessionGenerator,
+ str,
+ Callable[[HomeAssistant, TestClient], Awaitable[None]],
+ StrictConnectionMode,
+ ],
+ Awaitable[None],
+ ],
+ strict_connection_mode: StrictConnectionMode,
+ request_func: Callable[[HomeAssistant, TestClient], Awaitable[None]],
+) -> None:
+ """Test external unauthenticated requests with strict connection non cloud."""
+ await test_func(
+ hass,
+ app_strict_connection,
+ aiohttp_client,
+ hass_access_token,
+ request_func,
+ strict_connection_mode,
+ )
diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py
index 9e892e2ee43..b84da595ab1 100644
--- a/tests/components/http/test_init.py
+++ b/tests/components/http/test_init.py
@@ -7,6 +7,7 @@ from ipaddress import ip_network
import logging
from pathlib import Path
from unittest.mock import Mock, patch
+from urllib.parse import quote_plus
import pytest
@@ -14,7 +15,10 @@ from homeassistant.auth.providers.legacy_api_password import (
LegacyApiPasswordAuthProvider,
)
from homeassistant.components import http
+from homeassistant.components.http.const import StrictConnectionMode
+from homeassistant.config import async_process_ha_core_config
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.http import KEY_HASS
from homeassistant.helpers.network import NoURLAvailableError
from homeassistant.setup import async_setup_component
@@ -521,3 +525,78 @@ async def test_logging(
response = await client.get("/api/states/logging.entity")
assert response.status == HTTPStatus.OK
assert "GET /api/states/logging.entity" not in caplog.text
+
+
+async def test_service_create_temporary_strict_connection_url_strict_connection_disabled(
+ hass: HomeAssistant,
+) -> None:
+ """Test service create_temporary_strict_connection_url with strict_connection not enabled."""
+ assert await async_setup_component(hass, http.DOMAIN, {"http": {}})
+ with pytest.raises(
+ ServiceValidationError,
+ match="Strict connection is not enabled for non-cloud requests",
+ ):
+ await hass.services.async_call(
+ http.DOMAIN,
+ "create_temporary_strict_connection_url",
+ blocking=True,
+ return_response=True,
+ )
+
+
+@pytest.mark.parametrize(
+ ("mode"),
+ [
+ StrictConnectionMode.DROP_CONNECTION,
+ StrictConnectionMode.STATIC_PAGE,
+ ],
+)
+async def test_service_create_temporary_strict_connection(
+ hass: HomeAssistant, mode: StrictConnectionMode
+) -> None:
+ """Test service create_temporary_strict_connection_url."""
+ assert await async_setup_component(
+ hass, http.DOMAIN, {"http": {"strict_connection": mode}}
+ )
+
+ # No external url set
+ assert hass.config.external_url is None
+ assert hass.config.internal_url is None
+ with pytest.raises(ServiceValidationError, match="No external URL available"):
+ await hass.services.async_call(
+ http.DOMAIN,
+ "create_temporary_strict_connection_url",
+ blocking=True,
+ return_response=True,
+ )
+
+ # Raise if only internal url is available
+ hass.config.api = Mock(use_ssl=False, port=8123, local_ip="192.168.123.123")
+ with pytest.raises(ServiceValidationError, match="No external URL available"):
+ await hass.services.async_call(
+ http.DOMAIN,
+ "create_temporary_strict_connection_url",
+ blocking=True,
+ return_response=True,
+ )
+
+ # Set external url too
+ external_url = "https://example.com"
+ await async_process_ha_core_config(
+ hass,
+ {"external_url": external_url},
+ )
+ assert hass.config.external_url == external_url
+ response = await hass.services.async_call(
+ http.DOMAIN,
+ "create_temporary_strict_connection_url",
+ blocking=True,
+ return_response=True,
+ )
+ assert isinstance(response, dict)
+ direct_url_prefix = f"{external_url}/auth/strict_connection/temp_token?authSig="
+ assert response.pop("direct_url").startswith(direct_url_prefix)
+ assert response.pop("url").startswith(
+ f"https://login.home-assistant.io?u={quote_plus(direct_url_prefix)}"
+ )
+ assert response == {} # No more keys in response
diff --git a/tests/components/http/test_session.py b/tests/components/http/test_session.py
new file mode 100644
index 00000000000..ae62365749a
--- /dev/null
+++ b/tests/components/http/test_session.py
@@ -0,0 +1,107 @@
+"""Tests for HTTP session."""
+
+from collections.abc import Callable
+import logging
+from typing import Any
+from unittest.mock import patch
+
+from aiohttp import web
+from aiohttp.test_utils import make_mocked_request
+import pytest
+
+from homeassistant.auth.session import SESSION_ID
+from homeassistant.components.http.session import (
+ COOKIE_NAME,
+ HomeAssistantCookieStorage,
+)
+from homeassistant.core import HomeAssistant
+
+
+def fake_request_with_strict_connection_cookie(cookie_value: str) -> web.Request:
+ """Return a fake request with a strict connection cookie."""
+ request = make_mocked_request(
+ "GET", "/", headers={"Cookie": f"{COOKIE_NAME}={cookie_value}"}
+ )
+ assert COOKIE_NAME in request.cookies
+ return request
+
+
+@pytest.fixture
+def cookie_storage(hass: HomeAssistant) -> HomeAssistantCookieStorage:
+ """Fixture for the cookie storage."""
+ return HomeAssistantCookieStorage(hass)
+
+
+def _encrypt_cookie_data(cookie_storage: HomeAssistantCookieStorage, data: Any) -> str:
+ """Encrypt cookie data."""
+ cookie_data = cookie_storage._encoder(data).encode("utf-8")
+ return cookie_storage._fernet.encrypt(cookie_data).decode("utf-8")
+
+
+@pytest.mark.parametrize(
+ "func",
+ [
+ lambda _: "invalid",
+ lambda storage: _encrypt_cookie_data(storage, "bla"),
+ lambda storage: _encrypt_cookie_data(storage, None),
+ ],
+)
+async def test_load_session_modified_cookies(
+ cookie_storage: HomeAssistantCookieStorage,
+ caplog: pytest.LogCaptureFixture,
+ func: Callable[[HomeAssistantCookieStorage], str],
+) -> None:
+ """Test that on modified cookies the session is empty and the request will be logged for ban."""
+ request = fake_request_with_strict_connection_cookie(func(cookie_storage))
+ with patch(
+ "homeassistant.components.http.session.process_wrong_login",
+ ) as mock_process_wrong_login:
+ session = await cookie_storage.load_session(request)
+ assert session.empty
+ assert (
+ "homeassistant.components.http.session",
+ logging.WARNING,
+ "Cannot decrypt/parse cookie value",
+ ) in caplog.record_tuples
+ mock_process_wrong_login.assert_called()
+
+
+async def test_load_session_validate_session(
+ hass: HomeAssistant,
+ cookie_storage: HomeAssistantCookieStorage,
+) -> None:
+ """Test load session validates the session."""
+ session = await cookie_storage.new_session()
+ session[SESSION_ID] = "bla"
+ request = fake_request_with_strict_connection_cookie(
+ _encrypt_cookie_data(cookie_storage, cookie_storage._get_session_data(session))
+ )
+
+ with patch.object(
+ hass.auth.session, "async_validate_strict_connection_session", return_value=True
+ ) as mock_validate:
+ session = await cookie_storage.load_session(request)
+ assert not session.empty
+ assert session[SESSION_ID] == "bla"
+ mock_validate.assert_called_with(session)
+
+ # verify lru_cache is working
+ mock_validate.reset_mock()
+ await cookie_storage.load_session(request)
+ mock_validate.assert_not_called()
+
+ session = await cookie_storage.new_session()
+ session[SESSION_ID] = "something"
+ request = fake_request_with_strict_connection_cookie(
+ _encrypt_cookie_data(cookie_storage, cookie_storage._get_session_data(session))
+ )
+
+ with patch.object(
+ hass.auth.session,
+ "async_validate_strict_connection_session",
+ return_value=False,
+ ):
+ session = await cookie_storage.load_session(request)
+ assert session.empty
+ assert SESSION_ID not in session
+ assert session._changed
diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py
index ad4ac78d064..14ed9fae5e0 100644
--- a/tests/components/humidifier/test_device_condition.py
+++ b/tests/components/humidifier/test_device_condition.py
@@ -187,8 +187,10 @@ async def test_if_state(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_on {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -206,8 +208,10 @@ async def test_if_state(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_off {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py
index e064e82a385..fd6441588c4 100644
--- a/tests/components/humidifier/test_device_trigger.py
+++ b/tests/components/humidifier/test_device_trigger.py
@@ -293,15 +293,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_on {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -317,15 +314,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -341,15 +335,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on_or_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_on_or_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py
index cd95248eb33..9d672b7ceb0 100644
--- a/tests/components/influxdb/test_init.py
+++ b/tests/components/influxdb/test_init.py
@@ -3,6 +3,7 @@
from dataclasses import dataclass
import datetime
from http import HTTPStatus
+import logging
from unittest.mock import ANY, MagicMock, Mock, call, patch
import pytest
@@ -1574,10 +1575,23 @@ async def test_invalid_inputs_error(
write_api = get_write_api(mock_client)
write_api.side_effect = test_exception
- with patch(f"{INFLUX_PATH}.time.sleep") as sleep:
+ log_emit_done = hass.loop.create_future()
+
+ original_emit = caplog.handler.emit
+
+ def wait_for_emit(record: logging.LogRecord) -> None:
+ original_emit(record)
+ if record.levelname == "ERROR":
+ hass.loop.call_soon_threadsafe(log_emit_done.set_result, None)
+
+ with (
+ patch(f"{INFLUX_PATH}.time.sleep") as sleep,
+ patch.object(caplog.handler, "emit", wait_for_emit),
+ ):
hass.states.async_set("fake.something", 1)
await hass.async_block_till_done()
await async_wait_for_queue_to_process(hass)
+ await log_emit_done
await hass.async_block_till_done()
write_api.assert_called_once()
diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py
index 65cff43c8d4..799120e3966 100644
--- a/tests/components/ipma/__init__.py
+++ b/tests/components/ipma/__init__.py
@@ -1,8 +1,12 @@
"""Tests for the IPMA component."""
-from collections import namedtuple
from datetime import UTC, datetime
+from pyipma.forecast import Forecast, Forecast_Location, Weather_Type
+from pyipma.observation import Observation
+from pyipma.rcm import RCM
+from pyipma.uv import UV
+
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME
ENTRY_CONFIG = {
@@ -18,109 +22,90 @@ class MockLocation:
async def fire_risk(self, api):
"""Mock Fire Risk."""
- RCM = namedtuple(
- "RCM",
- [
- "dico",
- "rcm",
- "coordinates",
- ],
- )
return RCM("some place", 3, (0, 0))
async def uv_risk(self, api):
"""Mock UV Index."""
- UV = namedtuple(
- "UV",
- ["idPeriodo", "intervaloHora", "data", "globalIdLocal", "iUv"],
- )
- return UV(0, "0", datetime.now(), 0, 5.7)
+ return UV(0, "0", datetime(2020, 1, 16, 0, 0, 0), 0, 5.7)
async def observation(self, api):
"""Mock Observation."""
- Observation = namedtuple(
- "Observation",
- [
- "accumulated_precipitation",
- "humidity",
- "pressure",
- "radiation",
- "temperature",
- "wind_direction",
- "wind_intensity_km",
- ],
+ return Observation(
+ precAcumulada=0.0,
+ humidade=71.0,
+ pressao=1000.0,
+ radiacao=0.0,
+ temperatura=18.0,
+ idDireccVento=8,
+ intensidadeVentoKM=3.94,
+ intensidadeVento=1.0944,
+ timestamp=datetime(2020, 1, 16, 0, 0, 0),
+ idEstacao=0,
)
- return Observation(0.0, 71.0, 1000.0, 0.0, 18.0, "NW", 3.94)
-
async def forecast(self, api, period):
"""Mock Forecast."""
- Forecast = namedtuple(
- "Forecast",
- [
- "feels_like_temperature",
- "forecast_date",
- "forecasted_hours",
- "humidity",
- "max_temperature",
- "min_temperature",
- "precipitation_probability",
- "temperature",
- "update_date",
- "weather_type",
- "wind_direction",
- "wind_strength",
- ],
- )
-
- WeatherType = namedtuple("WeatherType", ["id", "en", "pt"])
if period == 24:
return [
Forecast(
- None,
- datetime(2020, 1, 16, 0, 0, 0),
- 24,
- None,
- 16.2,
- 10.6,
- "100.0",
- 13.4,
- "2020-01-15T07:51:00",
- WeatherType(9, "Rain/showers", "Chuva/aguaceiros"),
- "S",
- "10",
+ utci=None,
+ dataPrev=datetime(2020, 1, 16, 0, 0, 0),
+ idPeriodo=24,
+ hR=None,
+ tMax=16.2,
+ tMin=10.6,
+ probabilidadePrecipita=100.0,
+ tMed=13.4,
+ dataUpdate=datetime(2020, 1, 15, 7, 51, 0),
+ idTipoTempo=Weather_Type(9, "Rain/showers", "Chuva/aguaceiros"),
+ ddVento="S",
+ ffVento=10,
+ idFfxVento=0,
+ iUv=0,
+ intervaloHora="",
+ location=Forecast_Location(0, "", 0, 0, 0, "", (0, 0)),
),
]
if period == 1:
return [
Forecast(
- "7.7",
- datetime(2020, 1, 15, 1, 0, 0, tzinfo=UTC),
- 1,
- "86.9",
- 12.0,
- None,
- 80.0,
- 10.6,
- "2020-01-15T02:51:00",
- WeatherType(10, "Light rain", "Chuva fraca ou chuvisco"),
- "S",
- "32.7",
+ utci=7.7,
+ dataPrev=datetime(2020, 1, 15, 1, 0, 0, tzinfo=UTC),
+ idPeriodo=1,
+ hR=86.9,
+ tMax=12.0,
+ tMin=None,
+ probabilidadePrecipita=80.0,
+ tMed=10.6,
+ dataUpdate=datetime(2020, 1, 15, 2, 51, 0),
+ idTipoTempo=Weather_Type(
+ 10, "Light rain", "Chuva fraca ou chuvisco"
+ ),
+ ddVento="S",
+ ffVento=32.7,
+ idFfxVento=0,
+ iUv=0,
+ intervaloHora="",
+ location=Forecast_Location(0, "", 0, 0, 0, "", (0, 0)),
),
Forecast(
- "5.7",
- datetime(2020, 1, 15, 2, 0, 0, tzinfo=UTC),
- 1,
- "86.9",
- 12.0,
- None,
- 80.0,
- 10.6,
- "2020-01-15T02:51:00",
- WeatherType(1, "Clear sky", "C\u00e9u limpo"),
- "S",
- "32.7",
+ utci=5.7,
+ dataPrev=datetime(2020, 1, 15, 2, 0, 0, tzinfo=UTC),
+ idPeriodo=1,
+ hR=86.9,
+ tMax=12.0,
+ tMin=None,
+ probabilidadePrecipita=80.0,
+ tMed=10.6,
+ dataUpdate=datetime(2020, 1, 15, 2, 51, 0),
+ idTipoTempo=Weather_Type(1, "Clear sky", "C\u00e9u limpo"),
+ ddVento="S",
+ ffVento=32.7,
+ idFfxVento=0,
+ iUv=0,
+ intervaloHora="",
+ location=Forecast_Location(0, "", 0, 0, 0, "", (0, 0)),
),
]
diff --git a/tests/components/ipma/snapshots/test_diagnostics.ambr b/tests/components/ipma/snapshots/test_diagnostics.ambr
index c95364b6e4a..9d7d38db8c3 100644
--- a/tests/components/ipma/snapshots/test_diagnostics.ambr
+++ b/tests/components/ipma/snapshots/test_diagnostics.ambr
@@ -1,15 +1,10 @@
# serializer version: 1
# name: test_diagnostics
dict({
- 'current_weather': list([
- 0.0,
- 71.0,
- 1000.0,
- 0.0,
- 18.0,
- 'NW',
- 3.94,
- ]),
+ 'current_weather': dict({
+ '__type': "",
+ 'repr': 'Observation(intensidadeVentoKM=3.94, temperatura=18.0, radiacao=0.0, idDireccVento=8, precAcumulada=0.0, intensidadeVento=1.0944, humidade=71.0, pressao=1000.0, timestamp=datetime.datetime(2020, 1, 16, 0, 0), idEstacao=0)',
+ }),
'location_information': dict({
'global_id_local': 1130600,
'id_station': 1200545,
@@ -19,42 +14,14 @@
'station': 'HomeTown Station',
}),
'weather_forecast': list([
- list([
- '7.7',
- '2020-01-15T01:00:00+00:00',
- 1,
- '86.9',
- 12.0,
- None,
- 80.0,
- 10.6,
- '2020-01-15T02:51:00',
- list([
- 10,
- 'Light rain',
- 'Chuva fraca ou chuvisco',
- ]),
- 'S',
- '32.7',
- ]),
- list([
- '5.7',
- '2020-01-15T02:00:00+00:00',
- 1,
- '86.9',
- 12.0,
- None,
- 80.0,
- 10.6,
- '2020-01-15T02:51:00',
- list([
- 1,
- 'Clear sky',
- 'Céu limpo',
- ]),
- 'S',
- '32.7',
- ]),
+ dict({
+ '__type': "",
+ 'repr': "Forecast(tMed=10.6, tMin=None, ffVento=32.7, idFfxVento=0, dataUpdate=datetime.datetime(2020, 1, 15, 2, 51), tMax=12.0, iUv=0, intervaloHora='', idTipoTempo=Weather_Type(id=10, en='Light rain', pt='Chuva fraca ou chuvisco'), hR=86.9, location=Forecast_Location(globalIdLocal=0, local='', idRegiao=0, idDistrito=0, idConcelho=0, idAreaAviso='', coordinates=(0, 0)), probabilidadePrecipita=80.0, idPeriodo=1, dataPrev=datetime.datetime(2020, 1, 15, 1, 0, tzinfo=datetime.timezone.utc), ddVento='S', utci=7.7)",
+ }),
+ dict({
+ '__type': "",
+ 'repr': "Forecast(tMed=10.6, tMin=None, ffVento=32.7, idFfxVento=0, dataUpdate=datetime.datetime(2020, 1, 15, 2, 51), tMax=12.0, iUv=0, intervaloHora='', idTipoTempo=Weather_Type(id=1, en='Clear sky', pt='Céu limpo'), hR=86.9, location=Forecast_Location(globalIdLocal=0, local='', idRegiao=0, idDistrito=0, idConcelho=0, idAreaAviso='', coordinates=(0, 0)), probabilidadePrecipita=80.0, idPeriodo=1, dataPrev=datetime.datetime(2020, 1, 15, 2, 0, tzinfo=datetime.timezone.utc), ddVento='S', utci=5.7)",
+ }),
]),
})
# ---
diff --git a/tests/components/ipma/snapshots/test_weather.ambr b/tests/components/ipma/snapshots/test_weather.ambr
index 0a778776329..1142cb7cfe5 100644
--- a/tests/components/ipma/snapshots/test_weather.ambr
+++ b/tests/components/ipma/snapshots/test_weather.ambr
@@ -83,7 +83,7 @@
dict({
'condition': 'rainy',
'datetime': datetime.datetime(2020, 1, 16, 0, 0),
- 'precipitation_probability': '100.0',
+ 'precipitation_probability': 100.0,
'temperature': 16.2,
'templow': 10.6,
'wind_bearing': 'S',
@@ -121,7 +121,7 @@
dict({
'condition': 'rainy',
'datetime': datetime.datetime(2020, 1, 16, 0, 0),
- 'precipitation_probability': '100.0',
+ 'precipitation_probability': 100.0,
'temperature': 16.2,
'templow': 10.6,
'wind_bearing': 'S',
@@ -160,7 +160,7 @@
dict({
'condition': 'rainy',
'datetime': '2020-01-16T00:00:00',
- 'precipitation_probability': '100.0',
+ 'precipitation_probability': 100.0,
'temperature': 16.2,
'templow': 10.6,
'wind_bearing': 'S',
@@ -173,7 +173,7 @@
dict({
'condition': 'rainy',
'datetime': '2020-01-16T00:00:00',
- 'precipitation_probability': '100.0',
+ 'precipitation_probability': 100.0,
'temperature': 16.2,
'templow': 10.6,
'wind_bearing': 'S',
diff --git a/tests/components/kitchen_sink/test_notify.py b/tests/components/kitchen_sink/test_notify.py
new file mode 100644
index 00000000000..6d02bacb7be
--- /dev/null
+++ b/tests/components/kitchen_sink/test_notify.py
@@ -0,0 +1,66 @@
+"""The tests for the demo button component."""
+
+from collections.abc import AsyncGenerator
+from unittest.mock import patch
+
+from freezegun.api import FrozenDateTimeFactory
+import pytest
+
+from homeassistant.components.kitchen_sink import DOMAIN
+from homeassistant.components.notify import (
+ DOMAIN as NOTIFY_DOMAIN,
+ SERVICE_SEND_MESSAGE,
+)
+from homeassistant.components.notify.const import ATTR_MESSAGE
+from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+from homeassistant.util import dt as dt_util
+
+ENTITY_DIRECT_MESSAGE = "notify.mybox_personal_notifier"
+
+
+@pytest.fixture
+async def notify_only() -> AsyncGenerator[None, None]:
+ """Enable only the button platform."""
+ with patch(
+ "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM",
+ [Platform.NOTIFY],
+ ):
+ yield
+
+
+@pytest.fixture(autouse=True)
+async def setup_comp(hass: HomeAssistant, notify_only: None):
+ """Set up demo component."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
+ await hass.async_block_till_done()
+
+
+def test_setup_params(hass: HomeAssistant) -> None:
+ """Test the initial parameters."""
+ state = hass.states.get(ENTITY_DIRECT_MESSAGE)
+ assert state
+ assert state.state == STATE_UNKNOWN
+
+
+async def test_send_message(
+ hass: HomeAssistant, freezer: FrozenDateTimeFactory
+) -> None:
+ """Test pressing the button."""
+ state = hass.states.get(ENTITY_DIRECT_MESSAGE)
+ assert state
+ assert state.state == STATE_UNKNOWN
+
+ now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
+ freezer.move_to(now)
+ await hass.services.async_call(
+ NOTIFY_DOMAIN,
+ SERVICE_SEND_MESSAGE,
+ {ATTR_ENTITY_ID: ENTITY_DIRECT_MESSAGE, ATTR_MESSAGE: "You have an update!"},
+ blocking=True,
+ )
+
+ state = hass.states.get(ENTITY_DIRECT_MESSAGE)
+ assert state
+ assert state.state == now.isoformat()
diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py
index caaa51e86fa..eeee8530085 100644
--- a/tests/components/light/test_device_condition.py
+++ b/tests/components/light/test_device_condition.py
@@ -219,8 +219,10 @@ async def test_if_state(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_on {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -238,8 +240,10 @@ async def test_if_state(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_off {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -302,8 +306,10 @@ async def test_if_state_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_on {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -367,9 +373,9 @@ async def test_if_fires_on_for_condition(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- ("platform", "event.event_type")
+ "some": (
+ "is_off {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
)
},
},
diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py
index ea1c1c66b21..c38ab14061f 100644
--- a/tests/components/light/test_device_trigger.py
+++ b/tests/components/light/test_device_trigger.py
@@ -23,6 +23,14 @@ from tests.common import (
async_mock_service,
)
+DATA_TEMPLATE_ATTRIBUTES = (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
+)
+
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
@@ -212,16 +220,7 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
- )
+ "some": "turn_on " + DATA_TEMPLATE_ATTRIBUTES
},
},
},
@@ -236,16 +235,7 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
- )
+ "some": "turn_off " + DATA_TEMPLATE_ATTRIBUTES
},
},
},
@@ -260,16 +250,7 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on_or_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
- )
+ "some": "turn_on_or_off " + DATA_TEMPLATE_ATTRIBUTES
},
},
},
@@ -332,16 +313,7 @@ async def test_if_fires_on_state_change_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
- )
+ "some": "turn_on " + DATA_TEMPLATE_ATTRIBUTES
},
},
},
@@ -396,16 +368,7 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
- )
+ "some": "turn_off " + DATA_TEMPLATE_ATTRIBUTES
},
},
}
diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py
index 3f518143285..3ad992d4458 100644
--- a/tests/components/lock/test_device_trigger.py
+++ b/tests/components/lock/test_device_trigger.py
@@ -363,15 +363,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -388,15 +385,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_on {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -413,15 +407,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -438,15 +429,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_on {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py
index ab11683889d..4c507b4bd66 100644
--- a/tests/components/media_player/test_device_trigger.py
+++ b/tests/components/media_player/test_device_trigger.py
@@ -412,15 +412,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py
index c67312939b1..f39c963b45b 100644
--- a/tests/components/mobile_app/test_webhook.py
+++ b/tests/components/mobile_app/test_webhook.py
@@ -2,7 +2,7 @@
from binascii import unhexlify
from http import HTTPStatus
-from unittest.mock import patch
+from unittest.mock import ANY, patch
import pytest
@@ -317,7 +317,7 @@ async def test_webhook_handle_get_config(
"time_zone": hass_config["time_zone"],
"components": set(hass_config["components"]),
"version": hass_config["version"],
- "theme_color": "#03A9F4", # Default frontend theme color
+ "theme_color": ANY,
"entities": {
"mock-device-id": {"disabled": False},
"battery-state-id": {"disabled": False},
diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py
index f6eff0fd64b..62cf12958d3 100644
--- a/tests/components/modbus/conftest.py
+++ b/tests/components/modbus/conftest.py
@@ -52,6 +52,15 @@ def mock_pymodbus_fixture():
"""Mock pymodbus."""
mock_pb = mock.AsyncMock()
mock_pb.close = mock.MagicMock()
+ read_result = ReadResult([])
+ mock_pb.read_coils.return_value = read_result
+ mock_pb.read_discrete_inputs.return_value = read_result
+ mock_pb.read_input_registers.return_value = read_result
+ mock_pb.read_holding_registers.return_value = read_result
+ mock_pb.write_register.return_value = read_result
+ mock_pb.write_registers.return_value = read_result
+ mock_pb.write_coil.return_value = read_result
+ mock_pb.write_coils.return_value = read_result
with (
mock.patch(
"homeassistant.components.modbus.modbus.AsyncModbusTcpClient",
@@ -156,7 +165,7 @@ async def mock_pymodbus_exception_fixture(hass, do_exception, mock_modbus):
@pytest.fixture(name="mock_pymodbus_return")
async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus):
"""Trigger update call with time_changed event."""
- read_result = ReadResult(register_words) if register_words else None
+ read_result = ReadResult(register_words if register_words else [])
mock_modbus.read_coils.return_value = read_result
mock_modbus.read_discrete_inputs.return_value = read_result
mock_modbus.read_input_registers.return_value = read_result
@@ -165,6 +174,7 @@ async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus):
mock_modbus.write_registers.return_value = read_result
mock_modbus.write_coil.return_value = read_result
mock_modbus.write_coils.return_value = read_result
+ return mock_modbus
@pytest.fixture(name="mock_do_cycle")
diff --git a/tests/components/modbus/fixtures/configuration.yaml b/tests/components/modbus/fixtures/configuration.yaml
index 0f12ac88686..0a16d85e39d 100644
--- a/tests/components/modbus/fixtures/configuration.yaml
+++ b/tests/components/modbus/fixtures/configuration.yaml
@@ -3,3 +3,7 @@ modbus:
host: "testHost"
port: 5001
name: "testModbus"
+ sensors:
+ - name: "dummy"
+ address: 117
+ slave: 0
diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py
index 922022741b0..1219a04fb0c 100644
--- a/tests/components/modbus/test_init.py
+++ b/tests/components/modbus/test_init.py
@@ -25,6 +25,7 @@ import voluptuous as vol
from homeassistant import config as hass_config
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
+from homeassistant.components.modbus import async_reset_platform
from homeassistant.components.modbus.const import (
ATTR_ADDRESS,
ATTR_HUB,
@@ -1645,7 +1646,7 @@ async def test_shutdown(
],
)
async def test_stop_restart(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return
) -> None:
"""Run test for service stop."""
@@ -1656,7 +1657,7 @@ async def test_stop_restart(
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "17"
- mock_modbus.reset_mock()
+ mock_pymodbus_return.reset_mock()
caplog.clear()
data = {
ATTR_HUB: TEST_MODBUS_NAME,
@@ -1664,23 +1665,23 @@ async def test_stop_restart(
await hass.services.async_call(DOMAIN, SERVICE_STOP, data, blocking=True)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
- assert mock_modbus.close.called
+ assert mock_pymodbus_return.close.called
assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text
- mock_modbus.reset_mock()
+ mock_pymodbus_return.reset_mock()
caplog.clear()
await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True)
await hass.async_block_till_done()
- assert not mock_modbus.close.called
- assert mock_modbus.connect.called
+ assert not mock_pymodbus_return.close.called
+ assert mock_pymodbus_return.connect.called
assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text
- mock_modbus.reset_mock()
+ mock_pymodbus_return.reset_mock()
caplog.clear()
await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True)
await hass.async_block_till_done()
- assert mock_modbus.close.called
- assert mock_modbus.connect.called
+ assert mock_pymodbus_return.close.called
+ assert mock_pymodbus_return.connect.called
assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text
assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text
@@ -1710,7 +1711,7 @@ async def test_write_no_client(hass: HomeAssistant, mock_modbus) -> None:
async def test_integration_reload(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
- mock_modbus,
+ mock_pymodbus_return,
freezer: FrozenDateTimeFactory,
) -> None:
"""Run test for integration reload."""
@@ -1731,7 +1732,7 @@ async def test_integration_reload(
@pytest.mark.parametrize("do_config", [{}])
async def test_integration_reload_failed(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return
) -> None:
"""Run test for integration connect failure on reload."""
caplog.set_level(logging.INFO)
@@ -1740,7 +1741,9 @@ async def test_integration_reload_failed(
yaml_path = get_fixture_path("configuration.yaml", "modbus")
with (
mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path),
- mock.patch.object(mock_modbus, "connect", side_effect=ModbusException("error")),
+ mock.patch.object(
+ mock_pymodbus_return, "connect", side_effect=ModbusException("error")
+ ),
):
await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True)
await hass.async_block_till_done()
@@ -1751,7 +1754,7 @@ async def test_integration_reload_failed(
@pytest.mark.parametrize("do_config", [{}])
async def test_integration_setup_failed(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return
) -> None:
"""Run test for integration setup on reload."""
with mock.patch.object(
@@ -1779,3 +1782,9 @@ async def test_no_entities(hass: HomeAssistant) -> None:
]
}
assert await async_setup_component(hass, DOMAIN, config) is False
+
+
+async def test_reset_platform(hass: HomeAssistant) -> None:
+ """Run test for async_reset_platform."""
+ await async_reset_platform(hass, "modbus")
+ assert DOMAIN not in hass.data
diff --git a/tests/components/netatmo/snapshots/test_binary_sensor.ambr b/tests/components/netatmo/snapshots/test_binary_sensor.ambr
new file mode 100644
index 00000000000..6a90b4dd77a
--- /dev/null
+++ b/tests/components/netatmo/snapshots/test_binary_sensor.ambr
@@ -0,0 +1,541 @@
+# serializer version: 1
+# name: test_entity[binary_sensor.baby_bedroom_connectivity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.baby_bedroom_connectivity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Connectivity',
+ 'platform': 'netatmo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '12:34:56:26:68:92-reachable',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entity[binary_sensor.baby_bedroom_connectivity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by Netatmo',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'Baby Bedroom Connectivity',
+ 'latitude': 13.377726,
+ 'longitude': 52.516263,
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.baby_bedroom_connectivity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
+# name: test_entity[binary_sensor.bedroom_connectivity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.bedroom_connectivity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Connectivity',
+ 'platform': 'netatmo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '12:34:56:26:69:0c-reachable',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entity[binary_sensor.bedroom_connectivity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by Netatmo',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'Bedroom Connectivity',
+ 'latitude': 13.377726,
+ 'longitude': 52.516263,
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.bedroom_connectivity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'off',
+ })
+# ---
+# name: test_entity[binary_sensor.kitchen_connectivity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.kitchen_connectivity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Connectivity',
+ 'platform': 'netatmo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '12:34:56:25:cf:a8-reachable',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entity[binary_sensor.kitchen_connectivity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by Netatmo',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'Kitchen Connectivity',
+ 'latitude': 13.377726,
+ 'longitude': 52.516263,
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.kitchen_connectivity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
+# name: test_entity[binary_sensor.livingroom_connectivity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.livingroom_connectivity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Connectivity',
+ 'platform': 'netatmo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '12:34:56:26:65:14-reachable',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entity[binary_sensor.livingroom_connectivity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by Netatmo',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'Livingroom Connectivity',
+ 'latitude': 13.377726,
+ 'longitude': 52.516263,
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.livingroom_connectivity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
+# name: test_entity[binary_sensor.parents_bedroom_connectivity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.parents_bedroom_connectivity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Connectivity',
+ 'platform': 'netatmo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '12:34:56:3e:c5:46-reachable',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entity[binary_sensor.parents_bedroom_connectivity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by Netatmo',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'Parents Bedroom Connectivity',
+ 'latitude': 13.377726,
+ 'longitude': 52.516263,
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.parents_bedroom_connectivity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
+# name: test_entity[binary_sensor.villa_bathroom_connectivity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.villa_bathroom_connectivity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Connectivity',
+ 'platform': 'netatmo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '12:34:56:80:7e:18-reachable',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entity[binary_sensor.villa_bathroom_connectivity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by Netatmo',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'Villa Bathroom Connectivity',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.villa_bathroom_connectivity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
+# name: test_entity[binary_sensor.villa_bedroom_connectivity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.villa_bedroom_connectivity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Connectivity',
+ 'platform': 'netatmo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '12:34:56:80:44:92-reachable',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entity[binary_sensor.villa_bedroom_connectivity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by Netatmo',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'Villa Bedroom Connectivity',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.villa_bedroom_connectivity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
+# name: test_entity[binary_sensor.villa_connectivity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.villa_connectivity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Connectivity',
+ 'platform': 'netatmo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '12:34:56:80:bb:26-reachable',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entity[binary_sensor.villa_connectivity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by Netatmo',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'Villa Connectivity',
+ 'latitude': 46.123456,
+ 'longitude': 6.1234567,
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.villa_connectivity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
+# name: test_entity[binary_sensor.villa_garden_connectivity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.villa_garden_connectivity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Connectivity',
+ 'platform': 'netatmo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '12:34:56:03:1b:e4-reachable',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entity[binary_sensor.villa_garden_connectivity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by Netatmo',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'Villa Garden Connectivity',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.villa_garden_connectivity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
+# name: test_entity[binary_sensor.villa_outdoor_connectivity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.villa_outdoor_connectivity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Connectivity',
+ 'platform': 'netatmo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '12:34:56:80:1c:42-reachable',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entity[binary_sensor.villa_outdoor_connectivity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by Netatmo',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'Villa Outdoor Connectivity',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.villa_outdoor_connectivity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'off',
+ })
+# ---
+# name: test_entity[binary_sensor.villa_rain_connectivity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.villa_rain_connectivity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Connectivity',
+ 'platform': 'netatmo',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': '12:34:56:80:c1:ea-reachable',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_entity[binary_sensor.villa_rain_connectivity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by Netatmo',
+ 'device_class': 'connectivity',
+ 'friendly_name': 'Villa Rain Connectivity',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.villa_rain_connectivity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr
index ed5f4decc86..0684956adb8 100644
--- a/tests/components/netatmo/snapshots/test_sensor.ambr
+++ b/tests/components/netatmo/snapshots/test_sensor.ambr
@@ -118,7 +118,15 @@
'aliases': set({
}),
'area_id': None,
- 'capabilities': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'healthy',
+ 'fine',
+ 'fair',
+ 'poor',
+ 'unhealthy',
+ ]),
+ }),
'config_entry_id': ,
'device_class': None,
'device_id': ,
@@ -135,7 +143,7 @@
'name': None,
'options': dict({
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Health index',
'platform': 'netatmo',
@@ -150,16 +158,24 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by Netatmo',
+ 'device_class': 'enum',
'friendly_name': 'Baby Bedroom Health index',
'latitude': 13.377726,
'longitude': 52.516263,
+ 'options': list([
+ 'healthy',
+ 'fine',
+ 'fair',
+ 'poor',
+ 'unhealthy',
+ ]),
}),
'context': ,
'entity_id': 'sensor.baby_bedroom_health_index',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'Fine',
+ 'state': 'fine',
})
# ---
# name: test_entity[sensor.baby_bedroom_humidity-entry]
@@ -638,7 +654,15 @@
'aliases': set({
}),
'area_id': None,
- 'capabilities': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'healthy',
+ 'fine',
+ 'fair',
+ 'poor',
+ 'unhealthy',
+ ]),
+ }),
'config_entry_id': ,
'device_class': None,
'device_id': ,
@@ -655,7 +679,7 @@
'name': None,
'options': dict({
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Health index',
'platform': 'netatmo',
@@ -670,7 +694,15 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by Netatmo',
+ 'device_class': 'enum',
'friendly_name': 'Bedroom Health index',
+ 'options': list([
+ 'healthy',
+ 'fine',
+ 'fair',
+ 'poor',
+ 'unhealthy',
+ ]),
}),
'context': ,
'entity_id': 'sensor.bedroom_health_index',
@@ -2845,7 +2877,15 @@
'aliases': set({
}),
'area_id': None,
- 'capabilities': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'healthy',
+ 'fine',
+ 'fair',
+ 'poor',
+ 'unhealthy',
+ ]),
+ }),
'config_entry_id': ,
'device_class': None,
'device_id': ,
@@ -2862,7 +2902,7 @@
'name': None,
'options': dict({
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Health index',
'platform': 'netatmo',
@@ -2877,9 +2917,17 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by Netatmo',
+ 'device_class': 'enum',
'friendly_name': 'Kitchen Health index',
'latitude': 13.377726,
'longitude': 52.516263,
+ 'options': list([
+ 'healthy',
+ 'fine',
+ 'fair',
+ 'poor',
+ 'unhealthy',
+ ]),
}),
'context': ,
'entity_id': 'sensor.kitchen_health_index',
@@ -3916,7 +3964,15 @@
'aliases': set({
}),
'area_id': None,
- 'capabilities': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'healthy',
+ 'fine',
+ 'fair',
+ 'poor',
+ 'unhealthy',
+ ]),
+ }),
'config_entry_id': ,
'device_class': None,
'device_id': ,
@@ -3933,7 +3989,7 @@
'name': None,
'options': dict({
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Health index',
'platform': 'netatmo',
@@ -3948,9 +4004,17 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by Netatmo',
+ 'device_class': 'enum',
'friendly_name': 'Livingroom Health index',
'latitude': 13.377726,
'longitude': 52.516263,
+ 'options': list([
+ 'healthy',
+ 'fine',
+ 'fair',
+ 'poor',
+ 'unhealthy',
+ ]),
}),
'context': ,
'entity_id': 'sensor.livingroom_health_index',
@@ -4440,7 +4504,15 @@
'aliases': set({
}),
'area_id': None,
- 'capabilities': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'healthy',
+ 'fine',
+ 'fair',
+ 'poor',
+ 'unhealthy',
+ ]),
+ }),
'config_entry_id': ,
'device_class': None,
'device_id': ,
@@ -4457,7 +4529,7 @@
'name': None,
'options': dict({
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Health index',
'platform': 'netatmo',
@@ -4472,16 +4544,24 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by Netatmo',
+ 'device_class': 'enum',
'friendly_name': 'Parents Bedroom Health index',
'latitude': 13.377726,
'longitude': 52.516263,
+ 'options': list([
+ 'healthy',
+ 'fine',
+ 'fair',
+ 'poor',
+ 'unhealthy',
+ ]),
}),
'context': ,
'entity_id': 'sensor.parents_bedroom_health_index',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'Fine',
+ 'state': 'fine',
})
# ---
# name: test_entity[sensor.parents_bedroom_humidity-entry]
@@ -6073,7 +6153,18 @@
'aliases': set({
}),
'area_id': None,
- 'capabilities': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'n',
+ 'ne',
+ 'e',
+ 'se',
+ 's',
+ 'sw',
+ 'w',
+ 'nw',
+ ]),
+ }),
'config_entry_id': ,
'device_class': None,
'device_id': ,
@@ -6090,7 +6181,7 @@
'name': None,
'options': dict({
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Gust direction',
'platform': 'netatmo',
@@ -6105,14 +6196,25 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by Netatmo',
+ 'device_class': 'enum',
'friendly_name': 'Villa Garden Gust direction',
+ 'options': list([
+ 'n',
+ 'ne',
+ 'e',
+ 'se',
+ 's',
+ 'sw',
+ 'w',
+ 'nw',
+ ]),
}),
'context': ,
'entity_id': 'sensor.villa_garden_gust_direction',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'S',
+ 'state': 's',
})
# ---
# name: test_entity[sensor.villa_garden_gust_strength-entry]
@@ -6317,7 +6419,18 @@
'aliases': set({
}),
'area_id': None,
- 'capabilities': None,
+ 'capabilities': dict({
+ 'options': list([
+ 'n',
+ 'ne',
+ 'e',
+ 'se',
+ 's',
+ 'sw',
+ 'w',
+ 'nw',
+ ]),
+ }),
'config_entry_id': ,
'device_class': None,
'device_id': ,
@@ -6334,7 +6447,7 @@
'name': None,
'options': dict({
}),
- 'original_device_class': None,
+ 'original_device_class': ,
'original_icon': None,
'original_name': 'Wind direction',
'platform': 'netatmo',
@@ -6349,14 +6462,25 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by Netatmo',
+ 'device_class': 'enum',
'friendly_name': 'Villa Garden Wind direction',
+ 'options': list([
+ 'n',
+ 'ne',
+ 'e',
+ 'se',
+ 's',
+ 'sw',
+ 'w',
+ 'nw',
+ ]),
}),
'context': ,
'entity_id': 'sensor.villa_garden_wind_direction',
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'SW',
+ 'state': 'sw',
})
# ---
# name: test_entity[sensor.villa_garden_wind_speed-entry]
diff --git a/tests/components/netatmo/test_binary_sensor.py b/tests/components/netatmo/test_binary_sensor.py
new file mode 100644
index 00000000000..53aea461fde
--- /dev/null
+++ b/tests/components/netatmo/test_binary_sensor.py
@@ -0,0 +1,31 @@
+"""Support for Netatmo binary sensors."""
+
+from unittest.mock import AsyncMock
+
+import pytest
+from syrupy import SnapshotAssertion
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from tests.common import MockConfigEntry
+from tests.components.netatmo.common import snapshot_platform_entities
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_entity(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ netatmo_auth: AsyncMock,
+ snapshot: SnapshotAssertion,
+ entity_registry: er.EntityRegistry,
+) -> None:
+ """Test entities."""
+ await snapshot_platform_entities(
+ hass,
+ config_entry,
+ Platform.BINARY_SENSOR,
+ entity_registry,
+ snapshot,
+ )
diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py
index 672084d644d..55af74b3373 100644
--- a/tests/components/netatmo/test_init.py
+++ b/tests/components/netatmo/test_init.py
@@ -393,13 +393,7 @@ async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None:
"type": "Bearer",
"expires_in": 60,
"expires_at": time() + 1000,
- "scope": " ".join(
- [
- "read_smokedetector",
- "read_thermostat",
- "write_thermostat",
- ]
- ),
+ "scope": "read_smokedetector read_thermostat write_thermostat",
},
},
options={},
diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py
index d2cc20b8394..4fa64e59b11 100644
--- a/tests/components/netatmo/test_sensor.py
+++ b/tests/components/netatmo/test_sensor.py
@@ -136,7 +136,7 @@ async def test_process_rf(strength: int, expected: str) -> None:
@pytest.mark.parametrize(
("health", "expected"),
- [(4, "Unhealthy"), (3, "Poor"), (2, "Fair"), (1, "Fine"), (0, "Healthy")],
+ [(4, "unhealthy"), (3, "poor"), (2, "fair"), (1, "fine"), (0, "healthy")],
)
async def test_process_health(health: int, expected: str) -> None:
"""Test health index translation."""
@@ -165,17 +165,17 @@ async def test_process_health(health: int, expected: str) -> None:
),
("12:34:56:80:c1:ea-sum_rain_1", "villa_rain_rain_last_hour", "0"),
("12:34:56:80:c1:ea-sum_rain_24", "villa_rain_rain_today", "6.9"),
- ("12:34:56:03:1b:e4-windangle", "netatmoindoor_garden_direction", "SW"),
+ ("12:34:56:03:1b:e4-windangle", "netatmoindoor_garden_direction", "sw"),
(
"12:34:56:03:1b:e4-windangle_value",
"netatmoindoor_garden_angle",
"217",
),
- ("12:34:56:03:1b:e4-gustangle", "mystation_garden_gust_direction", "S"),
+ ("12:34:56:03:1b:e4-gustangle", "mystation_garden_gust_direction", "s"),
(
"12:34:56:03:1b:e4-gustangle",
"netatmoindoor_garden_gust_direction",
- "S",
+ "s",
),
(
"12:34:56:03:1b:e4-gustangle_value",
@@ -195,7 +195,7 @@ async def test_process_health(health: int, expected: str) -> None:
(
"12:34:56:26:68:92-health_idx",
"baby_bedroom_health",
- "Fine",
+ "fine",
),
(
"12:34:56:26:68:92-wifi_status",
diff --git a/tests/components/notify/conftest.py b/tests/components/notify/conftest.py
new file mode 100644
index 00000000000..23930132f7b
--- /dev/null
+++ b/tests/components/notify/conftest.py
@@ -0,0 +1,23 @@
+"""Fixtures for Notify platform tests."""
+
+from collections.abc import Generator
+
+import pytest
+
+from homeassistant.config_entries import ConfigFlow
+from homeassistant.core import HomeAssistant
+
+from tests.common import mock_config_flow, mock_platform
+
+
+class MockFlow(ConfigFlow):
+ """Test flow."""
+
+
+@pytest.fixture
+def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]:
+ """Mock config flow."""
+ mock_platform(hass, "test.config_flow")
+
+ with mock_config_flow("test", MockFlow):
+ yield
diff --git a/tests/components/notify/test_init.py b/tests/components/notify/test_init.py
index 0b75a3c4691..1ecfc0d9ecf 100644
--- a/tests/components/notify/test_init.py
+++ b/tests/components/notify/test_init.py
@@ -1,448 +1,213 @@
-"""The tests for notify services that change targets."""
+"""The tests for notify entity platform."""
-import asyncio
-from pathlib import Path
-from unittest.mock import Mock, patch
+import copy
+from unittest.mock import MagicMock
import pytest
-import yaml
+import voluptuous as vol
-from homeassistant import config as hass_config
from homeassistant.components import notify
-from homeassistant.const import SERVICE_RELOAD, Platform
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.discovery import async_load_platform
-from homeassistant.helpers.reload import async_setup_reload_service
-from homeassistant.setup import async_setup_component
+from homeassistant.components.notify import (
+ DOMAIN,
+ SERVICE_SEND_MESSAGE,
+ NotifyEntity,
+ NotifyEntityDescription,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
+from homeassistant.core import HomeAssistant, State
-from tests.common import MockPlatform, async_get_persistent_notifications, mock_platform
+from tests.common import (
+ MockConfigEntry,
+ MockEntity,
+ MockModule,
+ mock_integration,
+ mock_platform,
+ mock_restore_cache,
+ setup_test_component_platform,
+)
+
+TEST_KWARGS = {"message": "Test message"}
-class MockNotifyPlatform(MockPlatform):
- """Help to set up test notify service."""
+class MockNotifyEntity(MockEntity, NotifyEntity):
+ """Mock Email notitier entity to use in tests."""
- def __init__(self, async_get_service=None, get_service=None):
- """Return the notify service."""
- super().__init__()
- if get_service:
- self.get_service = get_service
- if async_get_service:
- self.async_get_service = async_get_service
+ send_message_mock_calls = MagicMock()
+
+ async def async_send_message(self, message: str) -> None:
+ """Send a notification message."""
+ self.send_message_mock_calls(message=message)
-def mock_notify_platform(
- hass, tmp_path, integration="notify", async_get_service=None, get_service=None
-):
- """Specialize the mock platform for notify."""
- loaded_platform = MockNotifyPlatform(async_get_service, get_service)
- mock_platform(hass, f"{integration}.notify", loaded_platform)
+class MockNotifyEntityNonAsync(MockEntity, NotifyEntity):
+ """Mock Email notitier entity to use in tests."""
- return loaded_platform
+ send_message_mock_calls = MagicMock()
+
+ def send_message(self, message: str) -> None:
+ """Send a notification message."""
+ self.send_message_mock_calls(message=message)
-async def test_same_targets(hass: HomeAssistant) -> None:
- """Test not changing the targets in a notify service."""
- test = NotificationService(hass)
- await test.async_setup(hass, "notify", "test")
- await test.async_register_services()
- await hass.async_block_till_done()
-
- assert hasattr(test, "registered_targets")
- assert test.registered_targets == {"test_a": 1, "test_b": 2}
-
- await test.async_register_services()
- await hass.async_block_till_done()
- assert test.registered_targets == {"test_a": 1, "test_b": 2}
+async def help_async_setup_entry_init(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> bool:
+ """Set up test config entry."""
+ await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
+ return True
-async def test_change_targets(hass: HomeAssistant) -> None:
- """Test changing the targets in a notify service."""
- test = NotificationService(hass)
- await test.async_setup(hass, "notify", "test")
- await test.async_register_services()
- await hass.async_block_till_done()
-
- assert hasattr(test, "registered_targets")
- assert test.registered_targets == {"test_a": 1, "test_b": 2}
-
- test.target_list = {"a": 0}
- await test.async_register_services()
- await hass.async_block_till_done()
- assert test.target_list == {"a": 0}
- assert test.registered_targets == {"test_a": 0}
+async def help_async_unload_entry(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> bool:
+ """Unload test config emntry."""
+ return await hass.config_entries.async_unload_platforms(
+ config_entry, [Platform.NOTIFY]
+ )
-async def test_add_targets(hass: HomeAssistant) -> None:
- """Test adding the targets in a notify service."""
- test = NotificationService(hass)
- await test.async_setup(hass, "notify", "test")
- await test.async_register_services()
- await hass.async_block_till_done()
-
- assert hasattr(test, "registered_targets")
- assert test.registered_targets == {"test_a": 1, "test_b": 2}
-
- test.target_list = {"a": 1, "b": 2, "c": 3}
- await test.async_register_services()
- await hass.async_block_till_done()
- assert test.target_list == {"a": 1, "b": 2, "c": 3}
- assert test.registered_targets == {"test_a": 1, "test_b": 2, "test_c": 3}
-
-
-async def test_remove_targets(hass: HomeAssistant) -> None:
- """Test removing targets from the targets in a notify service."""
- test = NotificationService(hass)
- await test.async_setup(hass, "notify", "test")
- await test.async_register_services()
- await hass.async_block_till_done()
-
- assert hasattr(test, "registered_targets")
- assert test.registered_targets == {"test_a": 1, "test_b": 2}
-
- test.target_list = {"c": 1}
- await test.async_register_services()
- await hass.async_block_till_done()
- assert test.target_list == {"c": 1}
- assert test.registered_targets == {"test_c": 1}
-
-
-class NotificationService(notify.BaseNotificationService):
- """A test class for notification services."""
-
- def __init__(self, hass, target_list={"a": 1, "b": 2}, name="notify"):
- """Initialize the service."""
-
- async def _async_make_reloadable(hass):
- """Initialize the reload service."""
- await async_setup_reload_service(hass, name, [notify.DOMAIN])
-
- self.hass = hass
- self.target_list = target_list
- hass.async_create_task(_async_make_reloadable(hass))
-
- @property
- def targets(self):
- """Return a dictionary of devices."""
- return self.target_list
-
-
-async def test_warn_template(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+@pytest.mark.parametrize(
+ "entity",
+ [
+ MockNotifyEntityNonAsync(name="test", entity_id="notify.test"),
+ MockNotifyEntity(name="test", entity_id="notify.test"),
+ ],
+ ids=["non_async", "async"],
+)
+async def test_send_message_service(
+ hass: HomeAssistant, config_flow_fixture: None, entity: NotifyEntity
) -> None:
- """Test warning when template used."""
- assert await async_setup_component(hass, "notify", {})
+ """Test send_message service."""
+
+ config_entry = MockConfigEntry(domain="test")
+ config_entry.add_to_hass(hass)
+
+ mock_integration(
+ hass,
+ MockModule(
+ "test",
+ async_setup_entry=help_async_setup_entry_init,
+ async_unload_entry=help_async_unload_entry,
+ ),
+ )
+ setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+
+ state = hass.states.get("notify.test")
+ assert state.state is STATE_UNKNOWN
await hass.services.async_call(
- "notify",
- "persistent_notification",
- {"message": "{{ 1 + 1 }}", "title": "Test notif {{ 1 + 1 }}"},
+ DOMAIN,
+ SERVICE_SEND_MESSAGE,
+ copy.deepcopy(TEST_KWARGS) | {"entity_id": "notify.test"},
blocking=True,
)
- # We should only log it once
- assert caplog.text.count("Passing templates to notify service is deprecated") == 1
- notifications = async_get_persistent_notifications(hass)
- assert len(notifications) == 1
-
-
-async def test_invalid_platform(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
-) -> None:
- """Test service setup with an invalid platform."""
- mock_notify_platform(hass, tmp_path, "testnotify1")
- # Setup the platform
- await async_setup_component(
- hass, "notify", {"notify": [{"platform": "testnotify1"}]}
- )
await hass.async_block_till_done()
- assert "Invalid notify platform" in caplog.text
- caplog.clear()
- # Setup the second testnotify2 platform dynamically
- mock_notify_platform(hass, tmp_path, "testnotify2")
- await async_load_platform(
- hass,
- "notify",
- "testnotify2",
- {},
- hass_config={"notify": [{"platform": "testnotify2"}]},
- )
- await hass.async_block_till_done()
- assert "Invalid notify platform" in caplog.text
+ entity.send_message_mock_calls.assert_called_once()
+ entity.send_message_mock_calls.reset_mock()
-async def test_invalid_service(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
-) -> None:
- """Test service setup with an invalid service object or platform."""
-
- def get_service(hass, config, discovery_info=None):
- """Return None for an invalid notify service."""
- return None
-
- mock_notify_platform(hass, tmp_path, "testnotify", get_service=get_service)
- # Setup the second testnotify2 platform dynamically
- await async_load_platform(
- hass,
- "notify",
- "testnotify",
- {},
- hass_config={"notify": [{"platform": "testnotify"}]},
- )
- await hass.async_block_till_done()
- assert "Failed to initialize notification service testnotify" in caplog.text
- caplog.clear()
-
- await async_load_platform(
- hass,
- "notify",
- "testnotifyinvalid",
- {"notify": [{"platform": "testnotifyinvalid"}]},
- hass_config={"notify": [{"platform": "testnotifyinvalid"}]},
- )
- await hass.async_block_till_done()
- assert "Unknown notification service specified" in caplog.text
-
-
-async def test_platform_setup_with_error(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
-) -> None:
- """Test service setup with an invalid setup."""
-
- async def async_get_service(hass, config, discovery_info=None):
- """Return None for an invalid notify service."""
- raise Exception("Setup error")
-
- mock_notify_platform(
- hass, tmp_path, "testnotify", async_get_service=async_get_service
- )
- # Setup the second testnotify2 platform dynamically
- await async_load_platform(
- hass,
- "notify",
- "testnotify",
- {},
- hass_config={"notify": [{"platform": "testnotify"}]},
- )
- await hass.async_block_till_done()
- assert "Error setting up platform testnotify" in caplog.text
-
-
-async def test_reload_with_notify_builtin_platform_reload(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
-) -> None:
- """Test reload using the notify platform reload method."""
-
- async def async_get_service(hass, config, discovery_info=None):
- """Get notify service for mocked platform."""
- targetlist = {"a": 1, "b": 2}
- return NotificationService(hass, targetlist, "testnotify")
-
- # platform with service
- mock_notify_platform(
- hass, tmp_path, "testnotify", async_get_service=async_get_service
- )
-
- # Perform a reload using the notify module for testnotify (without services)
- await notify.async_reload(hass, "testnotify")
-
- # Setup the platform
- await async_setup_component(
- hass, "notify", {"notify": [{"platform": "testnotify"}]}
- )
- await hass.async_block_till_done()
- assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
- assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
-
- # Perform a reload using the notify module for testnotify (with services)
- await notify.async_reload(hass, "testnotify")
- assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
- assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
-
-
-async def test_setup_platform_and_reload(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
-) -> None:
- """Test service setup and reload."""
- get_service_called = Mock()
-
- async def async_get_service(hass, config, discovery_info=None):
- """Get notify service for mocked platform."""
- get_service_called(config, discovery_info)
- targetlist = {"a": 1, "b": 2}
- return NotificationService(hass, targetlist, "testnotify")
-
- async def async_get_service2(hass, config, discovery_info=None):
- """Get notify service for mocked platform."""
- get_service_called(config, discovery_info)
- targetlist = {"c": 3, "d": 4}
- return NotificationService(hass, targetlist, "testnotify2")
-
- # Mock first platform
- mock_notify_platform(
- hass, tmp_path, "testnotify", async_get_service=async_get_service
- )
-
- # Initialize a second platform testnotify2
- mock_notify_platform(
- hass, tmp_path, "testnotify2", async_get_service=async_get_service2
- )
-
- # Setup the testnotify platform
- await async_setup_component(
- hass, "notify", {"notify": [{"platform": "testnotify"}]}
- )
- await hass.async_block_till_done()
- assert hass.services.has_service("testnotify", SERVICE_RELOAD)
- assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
- assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
- assert get_service_called.call_count == 1
- assert get_service_called.call_args[0][0] == {"platform": "testnotify"}
- assert get_service_called.call_args[0][1] is None
- get_service_called.reset_mock()
-
- # Setup the second testnotify2 platform dynamically
- await async_load_platform(
- hass,
- "notify",
- "testnotify2",
- {},
- hass_config={"notify": [{"platform": "testnotify"}]},
- )
- await hass.async_block_till_done()
- assert hass.services.has_service("testnotify2", SERVICE_RELOAD)
- assert hass.services.has_service(notify.DOMAIN, "testnotify2_c")
- assert hass.services.has_service(notify.DOMAIN, "testnotify2_d")
- assert get_service_called.call_count == 1
- assert get_service_called.call_args[0][0] == {}
- assert get_service_called.call_args[0][1] == {}
- get_service_called.reset_mock()
-
- # Perform a reload
- new_yaml_config_file = tmp_path / "configuration.yaml"
- new_yaml_config = yaml.dump({"notify": [{"platform": "testnotify"}]})
- new_yaml_config_file.write_text(new_yaml_config)
-
- with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file):
+ # Test schema: `None` message fails
+ with pytest.raises(vol.Invalid) as exc:
await hass.services.async_call(
- "testnotify",
- SERVICE_RELOAD,
- {},
- blocking=True,
+ notify.DOMAIN,
+ notify.SERVICE_SEND_MESSAGE,
+ {"entity_id": "notify.test", notify.ATTR_MESSAGE: None},
)
+ assert (
+ str(exc.value) == "string value is None for dictionary value @ data['message']"
+ )
+ entity.send_message_mock_calls.assert_not_called()
+
+ # Test schema: No message fails
+ with pytest.raises(vol.Invalid) as exc:
await hass.services.async_call(
- "testnotify2",
- SERVICE_RELOAD,
- {},
- blocking=True,
+ notify.DOMAIN, notify.SERVICE_SEND_MESSAGE, {"entity_id": "notify.test"}
)
- await hass.async_block_till_done()
+ assert str(exc.value) == "required key not provided @ data['message']"
+ entity.send_message_mock_calls.assert_not_called()
- # Check if the notify services from setup still exist
- assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
- assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
- assert get_service_called.call_count == 1
- assert get_service_called.call_args[0][0] == {"platform": "testnotify"}
- assert get_service_called.call_args[0][1] is None
-
- # Check if the dynamically notify services from setup were removed
- assert not hass.services.has_service(notify.DOMAIN, "testnotify2_c")
- assert not hass.services.has_service(notify.DOMAIN, "testnotify2_d")
+ # Test unloading the entry succeeds
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
-async def test_setup_platform_before_notify_setup(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
+@pytest.mark.parametrize(
+ ("state", "init_state"),
+ [
+ ("2021-01-01T23:59:59+00:00", "2021-01-01T23:59:59+00:00"),
+ (STATE_UNAVAILABLE, STATE_UNKNOWN),
+ ],
+)
+async def test_restore_state(
+ hass: HomeAssistant, config_flow_fixture: None, state: str, init_state: str
) -> None:
- """Test trying to setup a platform before notify is setup."""
- get_service_called = Mock()
+ """Test we restore state integration."""
+ mock_restore_cache(hass, (State("notify.test", state),))
- async def async_get_service(hass, config, discovery_info=None):
- """Get notify service for mocked platform."""
- get_service_called(config, discovery_info)
- targetlist = {"a": 1, "b": 2}
- return NotificationService(hass, targetlist, "testnotify")
-
- async def async_get_service2(hass, config, discovery_info=None):
- """Get notify service for mocked platform."""
- get_service_called(config, discovery_info)
- targetlist = {"c": 3, "d": 4}
- return NotificationService(hass, targetlist, "testnotify2")
-
- # Mock first platform
- mock_notify_platform(
- hass, tmp_path, "testnotify", async_get_service=async_get_service
+ mock_integration(
+ hass,
+ MockModule(
+ "test",
+ async_setup_entry=help_async_setup_entry_init,
+ ),
)
- # Initialize a second platform testnotify2
- mock_notify_platform(
- hass, tmp_path, "testnotify2", async_get_service=async_get_service2
+ entity = MockNotifyEntity(name="test", entity_id="notify.test")
+ setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True)
+
+ config_entry = MockConfigEntry(domain="test")
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+
+ state = hass.states.get("notify.test")
+ assert state is not None
+ assert state.state is init_state
+
+
+async def test_name(hass: HomeAssistant, config_flow_fixture: None) -> None:
+ """Test notify name."""
+
+ mock_platform(hass, "test.config_flow")
+ mock_integration(
+ hass,
+ MockModule(
+ "test",
+ async_setup_entry=help_async_setup_entry_init,
+ ),
)
- hass_config = {"notify": [{"platform": "testnotify"}]}
+ # Unnamed notify entity -> no name
+ entity1 = NotifyEntity()
+ entity1.entity_id = "notify.test1"
- # Setup the second testnotify2 platform from discovery
- load_coro = async_load_platform(
- hass, Platform.NOTIFY, "testnotify2", {}, hass_config=hass_config
+ # Unnamed notify entity and has_entity_name True -> unnamed
+ entity2 = NotifyEntity()
+ entity2.entity_id = "notify.test3"
+ entity2._attr_has_entity_name = True
+
+ # Named notify entity and has_entity_name True -> named
+ entity3 = NotifyEntity()
+ entity3.entity_id = "notify.test4"
+ entity3.entity_description = NotifyEntityDescription("test", has_entity_name=True)
+
+ setup_test_component_platform(
+ hass, DOMAIN, [entity1, entity2, entity3], from_config_entry=True
)
- # Setup the testnotify platform
- setup_coro = async_setup_component(hass, "notify", hass_config)
-
- load_task = asyncio.create_task(load_coro)
- setup_task = asyncio.create_task(setup_coro)
-
- await asyncio.gather(load_task, setup_task)
-
+ config_entry = MockConfigEntry(domain="test")
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
- assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
- assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
- assert hass.services.has_service(notify.DOMAIN, "testnotify2_c")
- assert hass.services.has_service(notify.DOMAIN, "testnotify2_d")
+ state = hass.states.get(entity1.entity_id)
+ assert state
+ assert state.attributes == {}
-async def test_setup_platform_after_notify_setup(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
-) -> None:
- """Test trying to setup a platform after notify is setup."""
- get_service_called = Mock()
+ state = hass.states.get(entity2.entity_id)
+ assert state
+ assert state.attributes == {}
- async def async_get_service(hass, config, discovery_info=None):
- """Get notify service for mocked platform."""
- get_service_called(config, discovery_info)
- targetlist = {"a": 1, "b": 2}
- return NotificationService(hass, targetlist, "testnotify")
-
- async def async_get_service2(hass, config, discovery_info=None):
- """Get notify service for mocked platform."""
- get_service_called(config, discovery_info)
- targetlist = {"c": 3, "d": 4}
- return NotificationService(hass, targetlist, "testnotify2")
-
- # Mock first platform
- mock_notify_platform(
- hass, tmp_path, "testnotify", async_get_service=async_get_service
- )
-
- # Initialize a second platform testnotify2
- mock_notify_platform(
- hass, tmp_path, "testnotify2", async_get_service=async_get_service2
- )
-
- hass_config = {"notify": [{"platform": "testnotify"}]}
-
- # Setup the second testnotify2 platform from discovery
- load_coro = async_load_platform(
- hass, Platform.NOTIFY, "testnotify2", {}, hass_config=hass_config
- )
-
- # Setup the testnotify platform
- setup_coro = async_setup_component(hass, "notify", hass_config)
-
- setup_task = asyncio.create_task(setup_coro)
- load_task = asyncio.create_task(load_coro)
-
- await asyncio.gather(load_task, setup_task)
-
- await hass.async_block_till_done()
- assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
- assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
- assert hass.services.has_service(notify.DOMAIN, "testnotify2_c")
- assert hass.services.has_service(notify.DOMAIN, "testnotify2_d")
+ state = hass.states.get(entity3.entity_id)
+ assert state
+ assert state.attributes == {}
diff --git a/tests/components/notify/test_legacy.py b/tests/components/notify/test_legacy.py
new file mode 100644
index 00000000000..71424beeda9
--- /dev/null
+++ b/tests/components/notify/test_legacy.py
@@ -0,0 +1,625 @@
+"""The tests for legacy notify services."""
+
+import asyncio
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+import voluptuous as vol
+import yaml
+
+from homeassistant import config as hass_config
+from homeassistant.components import notify
+from homeassistant.const import SERVICE_RELOAD, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.reload import async_setup_reload_service
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockPlatform, async_get_persistent_notifications, mock_platform
+
+
+class NotificationService(notify.BaseNotificationService):
+ """A test class for legacy notification services."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ target_list: dict[str, Any] | None = None,
+ name="notify",
+ ) -> None:
+ """Initialize the service."""
+
+ async def _async_make_reloadable(hass: HomeAssistant) -> None:
+ """Initialize the reload service."""
+ await async_setup_reload_service(hass, name, [notify.DOMAIN])
+
+ self.hass = hass
+ self.target_list = target_list or {"a": 1, "b": 2}
+ hass.async_create_task(_async_make_reloadable(hass))
+
+ @property
+ def targets(self):
+ """Return a dictionary of devices."""
+ return self.target_list
+
+
+class MockNotifyPlatform(MockPlatform):
+ """Help to set up a legacy test notify service."""
+
+ def __init__(self, async_get_service: Any = None, get_service: Any = None) -> None:
+ """Return a legacy notify service."""
+ super().__init__()
+ if get_service:
+ self.get_service = get_service
+ if async_get_service:
+ self.async_get_service = async_get_service
+
+
+def mock_notify_platform(
+ hass: HomeAssistant,
+ tmp_path: Path,
+ integration: str = "notify",
+ async_get_service: Any = None,
+ get_service: Any = None,
+):
+ """Specialize the mock platform for legacy notify service."""
+ loaded_platform = MockNotifyPlatform(async_get_service, get_service)
+ mock_platform(hass, f"{integration}.notify", loaded_platform)
+
+ return loaded_platform
+
+
+async def help_setup_notify(
+ hass: HomeAssistant, tmp_path: Path, targets: dict[str, None] | None = None
+) -> MagicMock:
+ """Help set up a platform notify service."""
+ send_message_mock = MagicMock()
+
+ class _TestNotifyService(notify.BaseNotificationService):
+ def __init__(self, targets: dict[str, None] | None) -> None:
+ """Initialize service."""
+ self._targets = targets
+ super().__init__()
+
+ @property
+ def targets(self) -> Mapping[str, Any] | None:
+ """Return a dictionary of registered targets."""
+ return self._targets
+
+ def send_message(self, message: str, **kwargs: Any) -> None:
+ """Send a message."""
+ send_message_mock(message, kwargs)
+
+ async def async_get_service(
+ hass: HomeAssistant,
+ config: ConfigType,
+ discovery_info: DiscoveryInfoType | None = None,
+ ) -> notify.BaseNotificationService:
+ """Get notify service for mocked platform."""
+ return _TestNotifyService(targets)
+
+ # Mock platform with service
+ mock_notify_platform(hass, tmp_path, "test", async_get_service=async_get_service)
+ # Setup the platform
+ await async_setup_component(hass, "notify", {"notify": [{"platform": "test"}]})
+ await hass.async_block_till_done()
+
+ # Return mock for assertion service calls
+ return send_message_mock
+
+
+async def test_same_targets(hass: HomeAssistant) -> None:
+ """Test not changing the targets in a legacy notify service."""
+ test = NotificationService(hass)
+ await test.async_setup(hass, "notify", "test")
+ await test.async_register_services()
+ await hass.async_block_till_done()
+
+ assert hasattr(test, "registered_targets")
+ assert test.registered_targets == {"test_a": 1, "test_b": 2}
+
+ await test.async_register_services()
+ await hass.async_block_till_done()
+ assert test.registered_targets == {"test_a": 1, "test_b": 2}
+
+
+async def test_change_targets(hass: HomeAssistant) -> None:
+ """Test changing the targets in a legacy notify service."""
+ test = NotificationService(hass)
+ await test.async_setup(hass, "notify", "test")
+ await test.async_register_services()
+ await hass.async_block_till_done()
+
+ assert hasattr(test, "registered_targets")
+ assert test.registered_targets == {"test_a": 1, "test_b": 2}
+
+ test.target_list = {"a": 0}
+ await test.async_register_services()
+ await hass.async_block_till_done()
+ assert test.target_list == {"a": 0}
+ assert test.registered_targets == {"test_a": 0}
+
+
+async def test_add_targets(hass: HomeAssistant) -> None:
+ """Test adding the targets in a legacy notify service."""
+ test = NotificationService(hass)
+ await test.async_setup(hass, "notify", "test")
+ await test.async_register_services()
+ await hass.async_block_till_done()
+
+ assert hasattr(test, "registered_targets")
+ assert test.registered_targets == {"test_a": 1, "test_b": 2}
+
+ test.target_list = {"a": 1, "b": 2, "c": 3}
+ await test.async_register_services()
+ await hass.async_block_till_done()
+ assert test.target_list == {"a": 1, "b": 2, "c": 3}
+ assert test.registered_targets == {"test_a": 1, "test_b": 2, "test_c": 3}
+
+
+async def test_remove_targets(hass: HomeAssistant) -> None:
+ """Test removing targets from the targets in a legacy notify service."""
+ test = NotificationService(hass)
+ await test.async_setup(hass, "notify", "test")
+ await test.async_register_services()
+ await hass.async_block_till_done()
+
+ assert hasattr(test, "registered_targets")
+ assert test.registered_targets == {"test_a": 1, "test_b": 2}
+
+ test.target_list = {"c": 1}
+ await test.async_register_services()
+ await hass.async_block_till_done()
+ assert test.target_list == {"c": 1}
+ assert test.registered_targets == {"test_c": 1}
+
+
+async def test_warn_template(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Test warning when template used."""
+ assert await async_setup_component(hass, "notify", {})
+
+ await hass.services.async_call(
+ "notify",
+ "persistent_notification",
+ {"message": "{{ 1 + 1 }}", "title": "Test notif {{ 1 + 1 }}"},
+ blocking=True,
+ )
+ # We should only log it once
+ assert caplog.text.count("Passing templates to notify service is deprecated") == 1
+ notifications = async_get_persistent_notifications(hass)
+ assert len(notifications) == 1
+
+
+async def test_invalid_platform(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
+) -> None:
+ """Test service setup with an invalid platform."""
+ mock_notify_platform(hass, tmp_path, "testnotify1")
+ # Setup the platform
+ await async_setup_component(
+ hass, "notify", {"notify": [{"platform": "testnotify1"}]}
+ )
+ await hass.async_block_till_done()
+ assert "Invalid notify platform" in caplog.text
+ caplog.clear()
+ # Setup the second testnotify2 platform dynamically
+ mock_notify_platform(hass, tmp_path, "testnotify2")
+ await async_load_platform(
+ hass,
+ "notify",
+ "testnotify2",
+ {},
+ hass_config={"notify": [{"platform": "testnotify2"}]},
+ )
+ await hass.async_block_till_done()
+ assert "Invalid notify platform" in caplog.text
+
+
+async def test_invalid_service(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
+) -> None:
+ """Test service setup with an invalid service object or platform."""
+
+ def get_service(hass, config, discovery_info=None):
+ """Return None for an invalid notify service."""
+ return None
+
+ mock_notify_platform(hass, tmp_path, "testnotify", get_service=get_service)
+ # Setup the second testnotify2 platform dynamically
+ await async_load_platform(
+ hass,
+ "notify",
+ "testnotify",
+ {},
+ hass_config={"notify": [{"platform": "testnotify"}]},
+ )
+ await hass.async_block_till_done()
+ assert "Failed to initialize notification service testnotify" in caplog.text
+ caplog.clear()
+
+ await async_load_platform(
+ hass,
+ "notify",
+ "testnotifyinvalid",
+ {"notify": [{"platform": "testnotifyinvalid"}]},
+ hass_config={"notify": [{"platform": "testnotifyinvalid"}]},
+ )
+ await hass.async_block_till_done()
+ assert "Unknown notification service specified" in caplog.text
+
+
+async def test_platform_setup_with_error(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
+) -> None:
+ """Test service setup with an invalid setup."""
+
+ async def async_get_service(hass, config, discovery_info=None):
+ """Return None for an invalid notify service."""
+ raise Exception("Setup error")
+
+ mock_notify_platform(
+ hass, tmp_path, "testnotify", async_get_service=async_get_service
+ )
+ # Setup the second testnotify2 platform dynamically
+ await async_load_platform(
+ hass,
+ "notify",
+ "testnotify",
+ {},
+ hass_config={"notify": [{"platform": "testnotify"}]},
+ )
+ await hass.async_block_till_done()
+ assert "Error setting up platform testnotify" in caplog.text
+
+
+async def test_reload_with_notify_builtin_platform_reload(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
+) -> None:
+ """Test reload using the legacy notify platform reload method."""
+
+ async def async_get_service(hass, config, discovery_info=None):
+ """Get notify service for mocked platform."""
+ targetlist = {"a": 1, "b": 2}
+ return NotificationService(hass, targetlist, "testnotify")
+
+ # platform with service
+ mock_notify_platform(
+ hass, tmp_path, "testnotify", async_get_service=async_get_service
+ )
+
+ # Perform a reload using the notify module for testnotify (without services)
+ await notify.async_reload(hass, "testnotify")
+
+ # Setup the platform
+ await async_setup_component(
+ hass, "notify", {"notify": [{"platform": "testnotify"}]}
+ )
+ await hass.async_block_till_done()
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
+
+ # Perform a reload using the notify module for testnotify (with services)
+ await notify.async_reload(hass, "testnotify")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
+
+
+async def test_setup_platform_and_reload(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
+) -> None:
+ """Test service setup and reload."""
+ get_service_called = Mock()
+
+ async def async_get_service(hass, config, discovery_info=None):
+ """Get notify service for mocked platform."""
+ get_service_called(config, discovery_info)
+ targetlist = {"a": 1, "b": 2}
+ return NotificationService(hass, targetlist, "testnotify")
+
+ async def async_get_service2(hass, config, discovery_info=None):
+ """Get legacy notify service for mocked platform."""
+ get_service_called(config, discovery_info)
+ targetlist = {"c": 3, "d": 4}
+ return NotificationService(hass, targetlist, "testnotify2")
+
+ # Mock first platform
+ mock_notify_platform(
+ hass, tmp_path, "testnotify", async_get_service=async_get_service
+ )
+
+ # Initialize a second platform testnotify2
+ mock_notify_platform(
+ hass, tmp_path, "testnotify2", async_get_service=async_get_service2
+ )
+
+ # Setup the testnotify platform
+ await async_setup_component(
+ hass, "notify", {"notify": [{"platform": "testnotify"}]}
+ )
+ await hass.async_block_till_done()
+ assert hass.services.has_service("testnotify", SERVICE_RELOAD)
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
+ assert get_service_called.call_count == 1
+ assert get_service_called.call_args[0][0] == {"platform": "testnotify"}
+ assert get_service_called.call_args[0][1] is None
+ get_service_called.reset_mock()
+
+ # Setup the second testnotify2 platform dynamically
+ await async_load_platform(
+ hass,
+ "notify",
+ "testnotify2",
+ {},
+ hass_config={"notify": [{"platform": "testnotify"}]},
+ )
+ await hass.async_block_till_done()
+ assert hass.services.has_service("testnotify2", SERVICE_RELOAD)
+ assert hass.services.has_service(notify.DOMAIN, "testnotify2_c")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify2_d")
+ assert get_service_called.call_count == 1
+ assert get_service_called.call_args[0][0] == {}
+ assert get_service_called.call_args[0][1] == {}
+ get_service_called.reset_mock()
+
+ # Perform a reload
+ new_yaml_config_file = tmp_path / "configuration.yaml"
+ new_yaml_config = yaml.dump({"notify": [{"platform": "testnotify"}]})
+ new_yaml_config_file.write_text(new_yaml_config)
+
+ with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file):
+ await hass.services.async_call(
+ "testnotify",
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.services.async_call(
+ "testnotify2",
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ # Check if the notify services from setup still exist
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
+ assert get_service_called.call_count == 1
+ assert get_service_called.call_args[0][0] == {"platform": "testnotify"}
+ assert get_service_called.call_args[0][1] is None
+
+ # Check if the dynamically notify services from setup were removed
+ assert not hass.services.has_service(notify.DOMAIN, "testnotify2_c")
+ assert not hass.services.has_service(notify.DOMAIN, "testnotify2_d")
+
+
+async def test_setup_platform_before_notify_setup(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
+) -> None:
+ """Test trying to setup a platform before legacy notify service is setup."""
+ get_service_called = Mock()
+
+ async def async_get_service(hass, config, discovery_info=None):
+ """Get notify service for mocked platform."""
+ get_service_called(config, discovery_info)
+ targetlist = {"a": 1, "b": 2}
+ return NotificationService(hass, targetlist, "testnotify")
+
+ async def async_get_service2(hass, config, discovery_info=None):
+ """Get notify service for mocked platform."""
+ get_service_called(config, discovery_info)
+ targetlist = {"c": 3, "d": 4}
+ return NotificationService(hass, targetlist, "testnotify2")
+
+ # Mock first platform
+ mock_notify_platform(
+ hass, tmp_path, "testnotify", async_get_service=async_get_service
+ )
+
+ # Initialize a second platform testnotify2
+ mock_notify_platform(
+ hass, tmp_path, "testnotify2", async_get_service=async_get_service2
+ )
+
+ hass_config = {"notify": [{"platform": "testnotify"}]}
+
+ # Setup the second testnotify2 platform from discovery
+ load_coro = async_load_platform(
+ hass, Platform.NOTIFY, "testnotify2", {}, hass_config=hass_config
+ )
+
+ # Setup the testnotify platform
+ setup_coro = async_setup_component(hass, "notify", hass_config)
+
+ load_task = asyncio.create_task(load_coro)
+ setup_task = asyncio.create_task(setup_coro)
+
+ await asyncio.gather(load_task, setup_task)
+
+ await hass.async_block_till_done()
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify2_c")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify2_d")
+
+
+async def test_setup_platform_after_notify_setup(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
+) -> None:
+ """Test trying to setup a platform after legacy notify service is set up."""
+ get_service_called = Mock()
+
+ async def async_get_service(hass, config, discovery_info=None):
+ """Get notify service for mocked platform."""
+ get_service_called(config, discovery_info)
+ targetlist = {"a": 1, "b": 2}
+ return NotificationService(hass, targetlist, "testnotify")
+
+ async def async_get_service2(hass, config, discovery_info=None):
+ """Get notify service for mocked platform."""
+ get_service_called(config, discovery_info)
+ targetlist = {"c": 3, "d": 4}
+ return NotificationService(hass, targetlist, "testnotify2")
+
+ # Mock first platform
+ mock_notify_platform(
+ hass, tmp_path, "testnotify", async_get_service=async_get_service
+ )
+
+ # Initialize a second platform testnotify2
+ mock_notify_platform(
+ hass, tmp_path, "testnotify2", async_get_service=async_get_service2
+ )
+
+ hass_config = {"notify": [{"platform": "testnotify"}]}
+
+ # Setup the second testnotify2 platform from discovery
+ load_coro = async_load_platform(
+ hass, Platform.NOTIFY, "testnotify2", {}, hass_config=hass_config
+ )
+
+ # Setup the testnotify platform
+ setup_coro = async_setup_component(hass, "notify", hass_config)
+
+ setup_task = asyncio.create_task(setup_coro)
+ load_task = asyncio.create_task(load_coro)
+
+ await asyncio.gather(load_task, setup_task)
+
+ await hass.async_block_till_done()
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_a")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify_b")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify2_c")
+ assert hass.services.has_service(notify.DOMAIN, "testnotify2_d")
+
+
+async def test_sending_none_message(hass: HomeAssistant, tmp_path: Path) -> None:
+ """Test send with None as message."""
+ send_message_mock = await help_setup_notify(hass, tmp_path)
+ with pytest.raises(vol.Invalid) as exc:
+ await hass.services.async_call(
+ notify.DOMAIN, notify.SERVICE_NOTIFY, {notify.ATTR_MESSAGE: None}
+ )
+ await hass.async_block_till_done()
+ assert (
+ str(exc.value)
+ == "template value is None for dictionary value @ data['message']"
+ )
+ send_message_mock.assert_not_called()
+
+
+async def test_sending_templated_message(hass: HomeAssistant, tmp_path: Path) -> None:
+ """Send a templated message."""
+ send_message_mock = await help_setup_notify(hass, tmp_path)
+ hass.states.async_set("sensor.temperature", 10)
+ data = {
+ notify.ATTR_MESSAGE: "{{states.sensor.temperature.state}}",
+ notify.ATTR_TITLE: "{{ states.sensor.temperature.name }}",
+ }
+ await hass.services.async_call(notify.DOMAIN, notify.SERVICE_NOTIFY, data)
+ await hass.async_block_till_done()
+ send_message_mock.assert_called_once_with(
+ "10", {"title": "temperature", "data": None}
+ )
+
+
+async def test_method_forwards_correct_data(
+ hass: HomeAssistant, tmp_path: Path
+) -> None:
+ """Test that all data from the service gets forwarded to service."""
+ send_message_mock = await help_setup_notify(hass, tmp_path)
+ data = {
+ notify.ATTR_MESSAGE: "my message",
+ notify.ATTR_TITLE: "my title",
+ notify.ATTR_DATA: {"hello": "world"},
+ }
+ await hass.services.async_call(notify.DOMAIN, notify.SERVICE_NOTIFY, data)
+ await hass.async_block_till_done()
+ send_message_mock.assert_called_once_with(
+ "my message", {"title": "my title", "data": {"hello": "world"}}
+ )
+
+
+async def test_calling_notify_from_script_loaded_from_yaml_without_title(
+ hass: HomeAssistant, tmp_path: Path
+) -> None:
+ """Test if we can call a notify from a script."""
+ send_message_mock = await help_setup_notify(hass, tmp_path)
+ step = {
+ "service": "notify.notify",
+ "data": {
+ "data": {"push": {"sound": "US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav"}}
+ },
+ "data_template": {"message": "Test 123 {{ 2 + 2 }}\n"},
+ }
+ await async_setup_component(
+ hass, "script", {"script": {"test": {"sequence": step}}}
+ )
+ await hass.services.async_call("script", "test")
+ await hass.async_block_till_done()
+ send_message_mock.assert_called_once_with(
+ "Test 123 4",
+ {"data": {"push": {"sound": "US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav"}}},
+ )
+
+
+async def test_calling_notify_from_script_loaded_from_yaml_with_title(
+ hass: HomeAssistant, tmp_path: Path
+) -> None:
+ """Test if we can call a notify from a script."""
+ send_message_mock = await help_setup_notify(hass, tmp_path)
+ step = {
+ "service": "notify.notify",
+ "data": {
+ "data": {"push": {"sound": "US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav"}}
+ },
+ "data_template": {"message": "Test 123 {{ 2 + 2 }}\n", "title": "Test"},
+ }
+ await async_setup_component(
+ hass, "script", {"script": {"test": {"sequence": step}}}
+ )
+ await hass.services.async_call("script", "test")
+ await hass.async_block_till_done()
+ send_message_mock.assert_called_once_with(
+ "Test 123 4",
+ {
+ "title": "Test",
+ "data": {
+ "push": {"sound": "US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav"}
+ },
+ },
+ )
+
+
+async def test_targets_are_services(hass: HomeAssistant, tmp_path: Path) -> None:
+ """Test that all targets are exposed as individual services."""
+ await help_setup_notify(hass, tmp_path, targets={"a": 1, "b": 2})
+ assert hass.services.has_service("notify", "notify") is not None
+ assert hass.services.has_service("notify", "test_a") is not None
+ assert hass.services.has_service("notify", "test_b") is not None
+
+
+async def test_messages_to_targets_route(hass: HomeAssistant, tmp_path: Path) -> None:
+ """Test message routing to specific target services."""
+ send_message_mock = await help_setup_notify(
+ hass, tmp_path, targets={"target_name": "test target id"}
+ )
+
+ await hass.services.async_call(
+ "notify",
+ "test_target_name",
+ {"message": "my message", "title": "my title", "data": {"hello": "world"}},
+ )
+ await hass.async_block_till_done()
+
+ send_message_mock.assert_called_once_with(
+ "my message",
+ {"target": ["test target id"], "title": "my title", "data": {"hello": "world"}},
+ )
diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py
index f87b7f121b5..1352a4a633d 100644
--- a/tests/components/rainbird/test_switch.py
+++ b/tests/components/rainbird/test_switch.py
@@ -146,20 +146,24 @@ async def test_switch_on(
@pytest.mark.parametrize(
- "zone_state_response",
- [ZONE_3_ON_RESPONSE],
+ ("zone_state_response", "start_state"),
+ [
+ (ZONE_3_ON_RESPONSE, "on"),
+ (ZONE_OFF_RESPONSE, "off"), # Already off
+ ],
)
async def test_switch_off(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
responses: list[AiohttpClientMockResponse],
+ start_state: str,
) -> None:
"""Test turning off irrigation switch."""
# Initially the test zone is on
zone = hass.states.get("switch.rain_bird_sprinkler_3")
assert zone is not None
- assert zone.state == "on"
+ assert zone.state == start_state
aioclient_mock.mock_calls.clear()
responses.extend(
diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py
index edfa7c5adf9..4fd14e82990 100644
--- a/tests/components/remote/test_device_condition.py
+++ b/tests/components/remote/test_device_condition.py
@@ -217,8 +217,10 @@ async def test_if_state(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_on {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -236,8 +238,10 @@ async def test_if_state(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_off {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -300,8 +304,10 @@ async def test_if_state_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_on {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -361,9 +367,9 @@ async def test_if_fires_on_for_condition(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- ("platform", "event.event_type")
+ "some": (
+ "is_off {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
)
},
},
diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py
index 1f80843be9a..68f7215186f 100644
--- a/tests/components/remote/test_device_trigger.py
+++ b/tests/components/remote/test_device_trigger.py
@@ -212,15 +212,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_on {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -236,15 +233,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -260,15 +254,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on_or_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_on_or_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -331,15 +322,12 @@ async def test_if_fires_on_state_change_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -395,15 +383,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/ring/common.py b/tests/components/ring/common.py
index c6852bf87d6..b129623aa95 100644
--- a/tests/components/ring/common.py
+++ b/tests/components/ring/common.py
@@ -15,4 +15,4 @@ async def setup_platform(hass, platform):
)
with patch("homeassistant.components.ring.PLATFORMS", [platform]):
assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await hass.async_block_till_done(wait_background_tasks=True)
diff --git a/tests/components/rtsp_to_webrtc/test_diagnostics.py b/tests/components/rtsp_to_webrtc/test_diagnostics.py
index e020ebfd5f3..ad3522686b6 100644
--- a/tests/components/rtsp_to_webrtc/test_diagnostics.py
+++ b/tests/components/rtsp_to_webrtc/test_diagnostics.py
@@ -23,8 +23,5 @@ async def test_entry_diagnostics(
"""Test config entry diagnostics."""
await setup_integration()
- assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == {
- "discovery": {"attempt": 1, "web.failure": 1, "webrtc.success": 1},
- "web": {},
- "webrtc": {},
- }
+ result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
+ assert "webrtc" in result
diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py
index 08de630f025..2a142633ab3 100644
--- a/tests/components/sensor/test_device_condition.py
+++ b/tests/components/sensor/test_device_condition.py
@@ -545,8 +545,10 @@ async def test_if_state_above(
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
}
@@ -612,8 +614,10 @@ async def test_if_state_above_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
}
@@ -679,8 +683,10 @@ async def test_if_state_below(
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
}
@@ -747,8 +753,10 @@ async def test_if_state_between(
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
}
diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py
index bb7337c0144..49e00a927b4 100644
--- a/tests/components/sensor/test_device_trigger.py
+++ b/tests/components/sensor/test_device_trigger.py
@@ -496,15 +496,12 @@ async def test_if_fires_on_state_above(
"action": {
"service": "test.automation",
"data_template": {
- "some": "bat_low {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "bat_low {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -564,15 +561,12 @@ async def test_if_fires_on_state_below(
"action": {
"service": "test.automation",
"data_template": {
- "some": "bat_low {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "bat_low {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -633,15 +627,12 @@ async def test_if_fires_on_state_between(
"action": {
"service": "test.automation",
"data_template": {
- "some": "bat_low {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "bat_low {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -712,15 +703,12 @@ async def test_if_fires_on_state_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "bat_low {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "bat_low {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -781,15 +769,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py
index 9ce23d99152..280d15cd1ef 100644
--- a/tests/components/stream/conftest.py
+++ b/tests/components/stream/conftest.py
@@ -14,7 +14,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Generator
-from http import HTTPStatus
import logging
import threading
from unittest.mock import Mock, patch
@@ -87,6 +86,17 @@ class HLSSync:
self._num_recvs = 0
self._num_finished = 0
+ def on_resp():
+ self._num_finished += 1
+ self.check_requests_ready()
+
+ class SyncResponse(web.Response):
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ on_resp()
+
+ self.response = SyncResponse
+
def reset_request_pool(self, num_requests: int, reset_finished=True):
"""Use to reset the request counter between segments."""
self._num_recvs = 0
@@ -120,12 +130,6 @@ class HLSSync:
self.check_requests_ready()
return self._original_not_found()
- def response(self, body, headers=None, status=HTTPStatus.OK):
- """Intercept the Response call so we know when the web handler is finished."""
- self._num_finished += 1
- self.check_requests_ready()
- return self._original_response(body=body, headers=headers, status=status)
-
async def recv(self, output: StreamOutput, **kw):
"""Intercept the recv call so we know when the response is blocking on recv."""
self._num_recvs += 1
@@ -164,7 +168,7 @@ def hls_sync():
),
patch(
"homeassistant.components.stream.hls.web.Response",
- side_effect=sync.response,
+ new=sync.response,
),
):
yield sync
diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py
index 50e070a4f68..e315ea8cdcd 100644
--- a/tests/components/sun/test_trigger.py
+++ b/tests/components/sun/test_trigger.py
@@ -127,8 +127,11 @@ async def test_sunset_trigger_with_offset(hass: HomeAssistant, calls) -> None:
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event", "offset"))
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.event }}"
+ " - {{ trigger.offset }}"
+ )
},
},
}
diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py
index e351daf2a5b..cd0a67fa992 100644
--- a/tests/components/switch/test_device_condition.py
+++ b/tests/components/switch/test_device_condition.py
@@ -217,8 +217,10 @@ async def test_if_state(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_on {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -236,8 +238,10 @@ async def test_if_state(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_off {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -300,8 +304,10 @@ async def test_if_state_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ "some": (
+ "is_on {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
+ )
},
},
},
@@ -360,9 +366,9 @@ async def test_if_fires_on_for_condition(
"action": {
"service": "test.automation",
"data_template": {
- "some": "is_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- ("platform", "event.event_type")
+ "some": (
+ "is_off {{ trigger.platform }}"
+ " - {{ trigger.event.event_type }}"
)
},
},
diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py
index 58803b0c6ac..c528f982ebb 100644
--- a/tests/components/switch/test_device_trigger.py
+++ b/tests/components/switch/test_device_trigger.py
@@ -212,15 +212,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_on {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -236,15 +233,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -260,15 +254,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_on_or_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_on_or_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -332,15 +323,12 @@ async def test_if_fires_on_state_change_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -397,15 +385,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py
index 0d7d765b988..0f95503c333 100644
--- a/tests/components/template/test_trigger.py
+++ b/tests/components/template/test_trigger.py
@@ -329,15 +329,12 @@ async def test_if_not_fires_because_fail(
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -430,15 +427,12 @@ async def test_if_fires_on_change_with_bad_template(
{
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -502,15 +496,12 @@ async def test_if_fires_on_change_with_for(hass: HomeAssistant, calls) -> None:
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -549,15 +540,12 @@ async def test_if_fires_on_change_with_for_advanced(
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -593,15 +581,12 @@ async def test_if_fires_on_change_with_for_0_advanced(
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr
new file mode 100644
index 00000000000..74eff27c4a0
--- /dev/null
+++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr
@@ -0,0 +1,295 @@
+# serializer version: 1
+# name: test_diagnostics
+ dict({
+ 'energysites': list([
+ dict({
+ 'backup_capable': True,
+ 'battery_power': 5060,
+ 'energy_left': 38896.47368421053,
+ 'generator_power': 0,
+ 'grid_power': 0,
+ 'grid_services_active': False,
+ 'grid_services_power': 0,
+ 'grid_status': 'Active',
+ 'island_status': 'on_grid',
+ 'load_power': 6245,
+ 'percentage_charged': 95.50537403739663,
+ 'solar_power': 1185,
+ 'storm_mode_active': False,
+ 'timestamp': '2024-01-01T00:00:00+00:00',
+ 'total_pack_energy': 40727,
+ 'wall_connectors': dict({
+ 'abd-123': dict({
+ 'din': 'abd-123',
+ 'wall_connector_fault_state': 2,
+ 'wall_connector_power': 0,
+ 'wall_connector_state': 2,
+ }),
+ 'bcd-234': dict({
+ 'din': 'bcd-234',
+ 'wall_connector_fault_state': 2,
+ 'wall_connector_power': 0,
+ 'wall_connector_state': 2,
+ }),
+ }),
+ }),
+ ]),
+ 'vehicles': list([
+ dict({
+ 'access_type': 'OWNER',
+ 'api_version': 71,
+ 'backseat_token': None,
+ 'backseat_token_updated_at': None,
+ 'ble_autopair_enrolled': False,
+ 'calendar_enabled': True,
+ 'charge_state_battery_heater_on': False,
+ 'charge_state_battery_level': 77,
+ 'charge_state_battery_range': 266.87,
+ 'charge_state_charge_amps': 16,
+ 'charge_state_charge_current_request': 16,
+ 'charge_state_charge_current_request_max': 16,
+ 'charge_state_charge_enable_request': True,
+ 'charge_state_charge_energy_added': 0,
+ 'charge_state_charge_limit_soc': 80,
+ 'charge_state_charge_limit_soc_max': 100,
+ 'charge_state_charge_limit_soc_min': 50,
+ 'charge_state_charge_limit_soc_std': 80,
+ 'charge_state_charge_miles_added_ideal': 0,
+ 'charge_state_charge_miles_added_rated': 0,
+ 'charge_state_charge_port_cold_weather_mode': False,
+ 'charge_state_charge_port_color': '',
+ 'charge_state_charge_port_door_open': True,
+ 'charge_state_charge_port_latch': 'Engaged',
+ 'charge_state_charge_rate': 0,
+ 'charge_state_charger_actual_current': 0,
+ 'charge_state_charger_phases': None,
+ 'charge_state_charger_pilot_current': 16,
+ 'charge_state_charger_power': 0,
+ 'charge_state_charger_voltage': 2,
+ 'charge_state_charging_state': 'Stopped',
+ 'charge_state_conn_charge_cable': 'IEC',
+ 'charge_state_est_battery_range': 275.04,
+ 'charge_state_fast_charger_brand': '',
+ 'charge_state_fast_charger_present': False,
+ 'charge_state_fast_charger_type': 'ACSingleWireCAN',
+ 'charge_state_ideal_battery_range': 266.87,
+ 'charge_state_max_range_charge_counter': 0,
+ 'charge_state_minutes_to_full_charge': 0,
+ 'charge_state_not_enough_power_to_heat': None,
+ 'charge_state_off_peak_charging_enabled': False,
+ 'charge_state_off_peak_charging_times': 'all_week',
+ 'charge_state_off_peak_hours_end_time': 900,
+ 'charge_state_preconditioning_enabled': False,
+ 'charge_state_preconditioning_times': 'all_week',
+ 'charge_state_scheduled_charging_mode': 'Off',
+ 'charge_state_scheduled_charging_pending': False,
+ 'charge_state_scheduled_charging_start_time': None,
+ 'charge_state_scheduled_charging_start_time_app': 600,
+ 'charge_state_scheduled_departure_time': 1704837600,
+ 'charge_state_scheduled_departure_time_minutes': 480,
+ 'charge_state_supercharger_session_trip_planner': False,
+ 'charge_state_time_to_full_charge': 0,
+ 'charge_state_timestamp': 1705707520649,
+ 'charge_state_trip_charging': False,
+ 'charge_state_usable_battery_level': 77,
+ 'charge_state_user_charge_enable_request': None,
+ 'climate_state_allow_cabin_overheat_protection': True,
+ 'climate_state_auto_seat_climate_left': False,
+ 'climate_state_auto_seat_climate_right': True,
+ 'climate_state_auto_steering_wheel_heat': False,
+ 'climate_state_battery_heater': False,
+ 'climate_state_battery_heater_no_power': None,
+ 'climate_state_cabin_overheat_protection': 'On',
+ 'climate_state_cabin_overheat_protection_actively_cooling': False,
+ 'climate_state_climate_keeper_mode': 'off',
+ 'climate_state_cop_activation_temperature': 'High',
+ 'climate_state_defrost_mode': 0,
+ 'climate_state_driver_temp_setting': 22,
+ 'climate_state_fan_status': 0,
+ 'climate_state_hvac_auto_request': 'On',
+ 'climate_state_inside_temp': 29.8,
+ 'climate_state_is_auto_conditioning_on': False,
+ 'climate_state_is_climate_on': False,
+ 'climate_state_is_front_defroster_on': False,
+ 'climate_state_is_preconditioning': False,
+ 'climate_state_is_rear_defroster_on': False,
+ 'climate_state_left_temp_direction': 251,
+ 'climate_state_max_avail_temp': 28,
+ 'climate_state_min_avail_temp': 15,
+ 'climate_state_outside_temp': 30,
+ 'climate_state_passenger_temp_setting': 22,
+ 'climate_state_remote_heater_control_enabled': False,
+ 'climate_state_right_temp_direction': 251,
+ 'climate_state_seat_heater_left': 0,
+ 'climate_state_seat_heater_rear_center': 0,
+ 'climate_state_seat_heater_rear_left': 0,
+ 'climate_state_seat_heater_rear_right': 0,
+ 'climate_state_seat_heater_right': 0,
+ 'climate_state_side_mirror_heaters': False,
+ 'climate_state_steering_wheel_heat_level': 0,
+ 'climate_state_steering_wheel_heater': False,
+ 'climate_state_supports_fan_only_cabin_overheat_protection': True,
+ 'climate_state_timestamp': 1705707520649,
+ 'climate_state_wiper_blade_heater': False,
+ 'color': None,
+ 'drive_state_active_route_latitude': '**REDACTED**',
+ 'drive_state_active_route_longitude': '**REDACTED**',
+ 'drive_state_active_route_miles_to_arrival': 0.039491,
+ 'drive_state_active_route_minutes_to_arrival': 0.103577,
+ 'drive_state_active_route_traffic_minutes_delay': 0,
+ 'drive_state_gps_as_of': 1701129612,
+ 'drive_state_heading': 185,
+ 'drive_state_latitude': '**REDACTED**',
+ 'drive_state_longitude': '**REDACTED**',
+ 'drive_state_native_latitude': '**REDACTED**',
+ 'drive_state_native_location_supported': 1,
+ 'drive_state_native_longitude': '**REDACTED**',
+ 'drive_state_native_type': 'wgs',
+ 'drive_state_power': -7,
+ 'drive_state_shift_state': None,
+ 'drive_state_speed': None,
+ 'drive_state_timestamp': 1705707520649,
+ 'granular_access_hide_private': False,
+ 'gui_settings_gui_24_hour_time': False,
+ 'gui_settings_gui_charge_rate_units': 'kW',
+ 'gui_settings_gui_distance_units': 'km/hr',
+ 'gui_settings_gui_range_display': 'Rated',
+ 'gui_settings_gui_temperature_units': 'C',
+ 'gui_settings_gui_tirepressure_units': 'Psi',
+ 'gui_settings_show_range_units': False,
+ 'gui_settings_timestamp': 1705707520649,
+ 'id': '**REDACTED**',
+ 'id_s': '**REDACTED**',
+ 'in_service': False,
+ 'state': 'online',
+ 'tokens': '**REDACTED**',
+ 'user_id': '**REDACTED**',
+ 'vehicle_config_aux_park_lamps': 'Eu',
+ 'vehicle_config_badge_version': 1,
+ 'vehicle_config_can_accept_navigation_requests': True,
+ 'vehicle_config_can_actuate_trunks': True,
+ 'vehicle_config_car_special_type': 'base',
+ 'vehicle_config_car_type': 'model3',
+ 'vehicle_config_charge_port_type': 'CCS',
+ 'vehicle_config_cop_user_set_temp_supported': False,
+ 'vehicle_config_dashcam_clip_save_supported': True,
+ 'vehicle_config_default_charge_to_max': False,
+ 'vehicle_config_driver_assist': 'TeslaAP3',
+ 'vehicle_config_ece_restrictions': False,
+ 'vehicle_config_efficiency_package': 'M32021',
+ 'vehicle_config_eu_vehicle': True,
+ 'vehicle_config_exterior_color': 'DeepBlue',
+ 'vehicle_config_exterior_trim': 'Black',
+ 'vehicle_config_exterior_trim_override': '',
+ 'vehicle_config_has_air_suspension': False,
+ 'vehicle_config_has_ludicrous_mode': False,
+ 'vehicle_config_has_seat_cooling': False,
+ 'vehicle_config_headlamp_type': 'Global',
+ 'vehicle_config_interior_trim_type': 'White2',
+ 'vehicle_config_key_version': 2,
+ 'vehicle_config_motorized_charge_port': True,
+ 'vehicle_config_paint_color_override': '0,9,25,0.7,0.04',
+ 'vehicle_config_performance_package': 'Base',
+ 'vehicle_config_plg': True,
+ 'vehicle_config_pws': True,
+ 'vehicle_config_rear_drive_unit': 'PM216MOSFET',
+ 'vehicle_config_rear_seat_heaters': 1,
+ 'vehicle_config_rear_seat_type': 0,
+ 'vehicle_config_rhd': True,
+ 'vehicle_config_roof_color': 'RoofColorGlass',
+ 'vehicle_config_seat_type': None,
+ 'vehicle_config_spoiler_type': 'None',
+ 'vehicle_config_sun_roof_installed': None,
+ 'vehicle_config_supports_qr_pairing': False,
+ 'vehicle_config_third_row_seats': 'None',
+ 'vehicle_config_timestamp': 1705707520649,
+ 'vehicle_config_trim_badging': '74d',
+ 'vehicle_config_use_range_badging': True,
+ 'vehicle_config_utc_offset': 36000,
+ 'vehicle_config_webcam_selfie_supported': True,
+ 'vehicle_config_webcam_supported': True,
+ 'vehicle_config_wheel_type': 'Pinwheel18CapKit',
+ 'vehicle_id': '**REDACTED**',
+ 'vehicle_state_api_version': 71,
+ 'vehicle_state_autopark_state_v2': 'unavailable',
+ 'vehicle_state_calendar_supported': True,
+ 'vehicle_state_car_version': '2023.44.30.8 06f534d46010',
+ 'vehicle_state_center_display_state': 0,
+ 'vehicle_state_dashcam_clip_save_available': True,
+ 'vehicle_state_dashcam_state': 'Recording',
+ 'vehicle_state_df': 0,
+ 'vehicle_state_dr': 0,
+ 'vehicle_state_fd_window': 0,
+ 'vehicle_state_feature_bitmask': 'fbdffbff,187f',
+ 'vehicle_state_fp_window': 0,
+ 'vehicle_state_ft': 0,
+ 'vehicle_state_is_user_present': False,
+ 'vehicle_state_locked': False,
+ 'vehicle_state_media_info_audio_volume': 2.6667,
+ 'vehicle_state_media_info_audio_volume_increment': 0.333333,
+ 'vehicle_state_media_info_audio_volume_max': 10.333333,
+ 'vehicle_state_media_info_media_playback_status': 'Stopped',
+ 'vehicle_state_media_info_now_playing_album': '',
+ 'vehicle_state_media_info_now_playing_artist': '',
+ 'vehicle_state_media_info_now_playing_duration': 0,
+ 'vehicle_state_media_info_now_playing_elapsed': 0,
+ 'vehicle_state_media_info_now_playing_source': 'Spotify',
+ 'vehicle_state_media_info_now_playing_station': '',
+ 'vehicle_state_media_info_now_playing_title': '',
+ 'vehicle_state_media_state_remote_control_enabled': True,
+ 'vehicle_state_notifications_supported': True,
+ 'vehicle_state_odometer': 6481.019282,
+ 'vehicle_state_parsed_calendar_supported': True,
+ 'vehicle_state_pf': 0,
+ 'vehicle_state_pr': 0,
+ 'vehicle_state_rd_window': 0,
+ 'vehicle_state_remote_start': False,
+ 'vehicle_state_remote_start_enabled': True,
+ 'vehicle_state_remote_start_supported': True,
+ 'vehicle_state_rp_window': 0,
+ 'vehicle_state_rt': 0,
+ 'vehicle_state_santa_mode': 0,
+ 'vehicle_state_sentry_mode': False,
+ 'vehicle_state_sentry_mode_available': True,
+ 'vehicle_state_service_mode': False,
+ 'vehicle_state_service_mode_plus': False,
+ 'vehicle_state_software_update_download_perc': 0,
+ 'vehicle_state_software_update_expected_duration_sec': 2700,
+ 'vehicle_state_software_update_install_perc': 1,
+ 'vehicle_state_software_update_status': '',
+ 'vehicle_state_software_update_version': ' ',
+ 'vehicle_state_speed_limit_mode_active': False,
+ 'vehicle_state_speed_limit_mode_current_limit_mph': 69,
+ 'vehicle_state_speed_limit_mode_max_limit_mph': 120,
+ 'vehicle_state_speed_limit_mode_min_limit_mph': 50,
+ 'vehicle_state_speed_limit_mode_pin_code_set': True,
+ 'vehicle_state_timestamp': 1705707520649,
+ 'vehicle_state_tpms_hard_warning_fl': False,
+ 'vehicle_state_tpms_hard_warning_fr': False,
+ 'vehicle_state_tpms_hard_warning_rl': False,
+ 'vehicle_state_tpms_hard_warning_rr': False,
+ 'vehicle_state_tpms_last_seen_pressure_time_fl': 1705700812,
+ 'vehicle_state_tpms_last_seen_pressure_time_fr': 1705700793,
+ 'vehicle_state_tpms_last_seen_pressure_time_rl': 1705700794,
+ 'vehicle_state_tpms_last_seen_pressure_time_rr': 1705700823,
+ 'vehicle_state_tpms_pressure_fl': 2.775,
+ 'vehicle_state_tpms_pressure_fr': 2.8,
+ 'vehicle_state_tpms_pressure_rl': 2.775,
+ 'vehicle_state_tpms_pressure_rr': 2.775,
+ 'vehicle_state_tpms_rcp_front_value': 2.9,
+ 'vehicle_state_tpms_rcp_rear_value': 2.9,
+ 'vehicle_state_tpms_soft_warning_fl': False,
+ 'vehicle_state_tpms_soft_warning_fr': False,
+ 'vehicle_state_tpms_soft_warning_rl': False,
+ 'vehicle_state_tpms_soft_warning_rr': False,
+ 'vehicle_state_valet_mode': False,
+ 'vehicle_state_valet_pin_needed': False,
+ 'vehicle_state_vehicle_name': 'Test',
+ 'vehicle_state_vehicle_self_test_progress': 0,
+ 'vehicle_state_vehicle_self_test_requested': False,
+ 'vehicle_state_webcam_available': True,
+ 'vin': '**REDACTED**',
+ }),
+ ]),
+ })
+# ---
diff --git a/tests/components/teslemetry/test_diagnostics.py b/tests/components/teslemetry/test_diagnostics.py
new file mode 100644
index 00000000000..fb8eb79a918
--- /dev/null
+++ b/tests/components/teslemetry/test_diagnostics.py
@@ -0,0 +1,23 @@
+"""Test the Telemetry Diagnostics."""
+
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.core import HomeAssistant
+
+from . import setup_platform
+
+from tests.components.diagnostics import get_diagnostics_for_config_entry
+from tests.typing import ClientSessionGenerator
+
+
+async def test_diagnostics(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test diagnostics."""
+
+ entry = await setup_platform(hass)
+
+ diag = await get_diagnostics_for_config_entry(hass, hass_client, entry)
+ assert diag == snapshot
diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr
index d30e6c74aef..6c355c8ddca 100644
--- a/tests/components/tessie/snapshots/test_media_player.ambr
+++ b/tests/components/tessie/snapshots/test_media_player.ambr
@@ -54,6 +54,13 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Test Media player',
+ 'media_album_name': 'Album',
+ 'media_artist': 'Artist',
+ 'media_duration': 60.0,
+ 'media_playlist': 'Playlist',
+ 'media_position': 30.0,
+ 'media_title': 'Song',
+ 'source': 'Spotify',
'supported_features': ,
'volume_level': 0.22580323309042688,
}),
@@ -62,6 +69,6 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': 'idle',
+ 'state': 'playing',
})
# ---
diff --git a/tests/components/tessie/test_media_player.py b/tests/components/tessie/test_media_player.py
index c9e4c3b84bc..008607b8018 100644
--- a/tests/components/tessie/test_media_player.py
+++ b/tests/components/tessie/test_media_player.py
@@ -22,6 +22,8 @@ async def test_media_player(
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
+ mock_get_state,
+ mock_get_status,
) -> None:
"""Tests that the media player entity is correct when idle."""
@@ -38,6 +40,7 @@ async def test_media_player(
# The refresh fixture has music playing
freezer.tick(WAIT)
async_fire_time_changed(hass)
+ await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_entry.entity_id) == snapshot(
name=f"{entity_entry.entity_id}-playing"
diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py
index 1ffd295bbc9..6ece4f818d1 100644
--- a/tests/components/update/test_device_trigger.py
+++ b/tests/components/update/test_device_trigger.py
@@ -214,15 +214,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "update_available {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "update_available {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -238,15 +235,12 @@ async def test_if_fires_on_state_change(
"action": {
"service": "test.automation",
"data_template": {
- "some": "no_update {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "no_update {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -314,15 +308,12 @@ async def test_if_fires_on_state_change_legacy(
"action": {
"service": "test.automation",
"data_template": {
- "some": "no_update {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "no_update {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
@@ -383,15 +374,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py
index b2273d905c1..bae57b1941f 100644
--- a/tests/components/vacuum/test_device_trigger.py
+++ b/tests/components/vacuum/test_device_trigger.py
@@ -356,15 +356,12 @@ async def test_if_fires_on_state_change_with_for(
"action": {
"service": "test.automation",
"data_template": {
- "some": "turn_off {{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "for",
- )
+ "some": (
+ "turn_off {{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.for }}"
)
},
},
diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py
index 35bf2402b6c..595dc7dcc32 100644
--- a/tests/components/websocket_api/test_auth.py
+++ b/tests/components/websocket_api/test_auth.py
@@ -221,7 +221,7 @@ async def test_auth_close_after_revoke(
hass.auth.async_remove_refresh_token(refresh_token)
msg = await websocket_client.receive()
- assert msg.type == aiohttp.WSMsgType.CLOSED
+ assert msg.type is aiohttp.WSMsgType.CLOSE
assert websocket_client.closed
diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py
index e96f1c4f903..2bd76accfdd 100644
--- a/tests/components/websocket_api/test_commands.py
+++ b/tests/components/websocket_api/test_commands.py
@@ -701,7 +701,7 @@ async def test_get_services(
assert msg["id"] == id_
assert msg["type"] == const.TYPE_RESULT
assert msg["success"]
- assert msg["result"] == hass.services.async_services()
+ assert msg["result"].keys() == hass.services.async_services().keys()
async def test_get_config(
diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py
index db186e4811b..6ce46a5d9fe 100644
--- a/tests/components/websocket_api/test_http.py
+++ b/tests/components/websocket_api/test_http.py
@@ -43,7 +43,7 @@ async def test_pending_msg_overflow(
for idx in range(10):
await websocket_client.send_json({"id": idx + 1, "type": "ping"})
msg = await websocket_client.receive()
- assert msg.type == WSMsgType.CLOSED
+ assert msg.type is WSMsgType.CLOSE
async def test_cleanup_on_cancellation(
@@ -249,7 +249,7 @@ async def test_pending_msg_peak(
)
msg = await websocket_client.receive()
- assert msg.type == WSMsgType.CLOSED
+ assert msg.type is WSMsgType.CLOSE
assert "Client unable to keep up with pending messages" in caplog.text
assert "Stayed over 5 for 5 seconds" in caplog.text
assert "overload" in caplog.text
@@ -297,7 +297,7 @@ async def test_pending_msg_peak_recovery(
msg = await websocket_client.receive()
assert msg.type == WSMsgType.TEXT
msg = await websocket_client.receive()
- assert msg.type == WSMsgType.CLOSED
+ assert msg.type is WSMsgType.CLOSE
assert "Client unable to keep up with pending messages" not in caplog.text
diff --git a/tests/components/websocket_api/test_init.py b/tests/components/websocket_api/test_init.py
index 9360ff4ef8a..b20fd1c2f7e 100644
--- a/tests/components/websocket_api/test_init.py
+++ b/tests/components/websocket_api/test_init.py
@@ -41,7 +41,7 @@ async def test_quiting_hass(hass: HomeAssistant, websocket_client) -> None:
msg = await websocket_client.receive()
- assert msg.type == WSMsgType.CLOSED
+ assert msg.type is WSMsgType.CLOSE
async def test_unknown_command(websocket_client) -> None:
diff --git a/tests/components/xbox/test_config_flow.py b/tests/components/xbox/test_config_flow.py
index 5abf9ad25d9..e547909f946 100644
--- a/tests/components/xbox/test_config_flow.py
+++ b/tests/components/xbox/test_config_flow.py
@@ -55,7 +55,7 @@ async def test_full_flow(
},
)
- scope = "+".join(["Xboxlive.signin", "Xboxlive.offline_access"])
+ scope = "Xboxlive.signin+Xboxlive.offline_access"
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py
index c254a9c15fe..5e128cc464a 100644
--- a/tests/components/zha/test_repairs.py
+++ b/tests/components/zha/test_repairs.py
@@ -265,17 +265,27 @@ async def test_no_warn_on_socket(hass: HomeAssistant) -> None:
mock_probe.assert_not_called()
-async def test_probe_failure_exception_handling(caplog) -> None:
+async def test_probe_failure_exception_handling(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
"""Test that probe failures are handled gracefully."""
+ logger = logging.getLogger(
+ "homeassistant.components.zha.repairs.wrong_silabs_firmware"
+ )
+ orig_level = logger.level
+
with (
+ caplog.at_level(logging.DEBUG),
patch(
"homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type",
side_effect=RuntimeError(),
- ),
- caplog.at_level(logging.DEBUG),
+ ) as mock_probe_app_type,
):
+ logger.setLevel(logging.DEBUG)
await probe_silabs_firmware_type("/dev/ttyZigbee")
+ logger.setLevel(orig_level)
+ mock_probe_app_type.assert_awaited()
assert "Failed to probe application type" in caplog.text
diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py
index 8987481f460..7e42f41f119 100644
--- a/tests/components/zone/test_trigger.py
+++ b/tests/components/zone/test_trigger.py
@@ -64,16 +64,13 @@ async def test_if_fires_on_zone_enter(hass: HomeAssistant, calls) -> None:
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "zone.name",
- "id",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.zone.name }}"
+ " - {{ trigger.id }}"
)
},
},
@@ -143,16 +140,13 @@ async def test_if_fires_on_zone_enter_uuid(hass: HomeAssistant, calls) -> None:
"action": {
"service": "test.automation",
"data_template": {
- "some": "{{ trigger.%s }}"
- % "}} - {{ trigger.".join(
- (
- "platform",
- "entity_id",
- "from_state.state",
- "to_state.state",
- "zone.name",
- "id",
- )
+ "some": (
+ "{{ trigger.platform }}"
+ " - {{ trigger.entity_id }}"
+ " - {{ trigger.from_state.state }}"
+ " - {{ trigger.to_state.state }}"
+ " - {{ trigger.zone.name }}"
+ " - {{ trigger.id }}"
)
},
},
diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py
index 59c4f7357f3..64f6d6bf9f5 100644
--- a/tests/helpers/test_entity_platform.py
+++ b/tests/helpers/test_entity_platform.py
@@ -1112,6 +1112,19 @@ async def test_entity_registry_updates_invalid_entity_id(hass: HomeAssistant) ->
assert hass.states.get("diff_domain.world") is None
+async def test_add_entity_with_invalid_id(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Test trying to add an entity with an invalid entity_id."""
+ platform = MockEntityPlatform(hass)
+ entity = MockEntity(entity_id="i.n.v.a.l.i.d")
+ await platform.async_add_entities([entity])
+ assert (
+ "Error adding entity i.n.v.a.l.i.d for domain "
+ "test_domain with platform test_platform" in caplog.text
+ )
+
+
async def test_device_info_called(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
diff --git a/tests/helpers/test_importlib.py b/tests/helpers/test_importlib.py
index 5683dd5cf94..5c9686233f9 100644
--- a/tests/helpers/test_importlib.py
+++ b/tests/helpers/test_importlib.py
@@ -41,16 +41,40 @@ async def test_async_import_module_failures(hass: HomeAssistant) -> None:
with (
patch(
"homeassistant.helpers.importlib.importlib.import_module",
- side_effect=ImportError,
+ side_effect=ValueError,
),
- pytest.raises(ImportError),
+ pytest.raises(ValueError),
+ ):
+ await importlib.async_import_module(hass, "test.module")
+
+ mock_module = MockModule()
+ # The failure should be not be cached
+ with (
+ patch(
+ "homeassistant.helpers.importlib.importlib.import_module",
+ return_value=mock_module,
+ ),
+ ):
+ assert await importlib.async_import_module(hass, "test.module") is mock_module
+
+
+async def test_async_import_module_failure_caches_module_not_found(
+ hass: HomeAssistant,
+) -> None:
+ """Test importing a module caches ModuleNotFound."""
+ with (
+ patch(
+ "homeassistant.helpers.importlib.importlib.import_module",
+ side_effect=ModuleNotFoundError,
+ ),
+ pytest.raises(ModuleNotFoundError),
):
await importlib.async_import_module(hass, "test.module")
mock_module = MockModule()
# The failure should be cached
with (
- pytest.raises(ImportError),
+ pytest.raises(ModuleNotFoundError),
patch(
"homeassistant.helpers.importlib.importlib.import_module",
return_value=mock_module,
diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py
index 74b8a86ce7c..b5e71f4c9d8 100644
--- a/tests/helpers/test_service.py
+++ b/tests/helpers/test_service.py
@@ -7,6 +7,7 @@ from typing import Any
from unittest.mock import AsyncMock, Mock, patch
import pytest
+from pytest_unordered import unordered
import voluptuous as vol
# To prevent circular import when running just this file
@@ -16,6 +17,7 @@ import homeassistant.components # noqa: F401
from homeassistant.components.group import DOMAIN as DOMAIN_GROUP, Group
from homeassistant.components.logger import DOMAIN as DOMAIN_LOGGER
from homeassistant.components.shell_command import DOMAIN as DOMAIN_SHELL_COMMAND
+from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH
from homeassistant.const import (
ATTR_ENTITY_ID,
ENTITY_MATCH_ALL,
@@ -785,7 +787,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None:
"""Test async_get_all_descriptions."""
group_config = {DOMAIN_GROUP: {}}
assert await async_setup_component(hass, DOMAIN_GROUP, group_config)
- assert await async_setup_component(hass, "system_health", {})
+ assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {})
with patch(
"homeassistant.helpers.service._load_services_files",
@@ -795,17 +797,20 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None:
# Test we only load services.yaml for integrations with services.yaml
# And system_health has no services
- assert proxy_load_services_files.mock_calls[0][1][1] == [
- await async_get_integration(hass, "group")
- ]
+ assert proxy_load_services_files.mock_calls[0][1][1] == unordered(
+ [
+ await async_get_integration(hass, DOMAIN_GROUP),
+ await async_get_integration(hass, "http"), # system_health requires http
+ ]
+ )
- assert len(descriptions) == 1
-
- assert "description" in descriptions["group"]["reload"]
- assert "fields" in descriptions["group"]["reload"]
+ assert len(descriptions) == 2
+ assert DOMAIN_GROUP in descriptions
+ assert "description" in descriptions[DOMAIN_GROUP]["reload"]
+ assert "fields" in descriptions[DOMAIN_GROUP]["reload"]
# Does not have services
- assert "system_health" not in descriptions
+ assert DOMAIN_SYSTEM_HEALTH not in descriptions
logger_config = {DOMAIN_LOGGER: {}}
@@ -833,8 +838,8 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None:
await async_setup_component(hass, DOMAIN_LOGGER, logger_config)
descriptions = await service.async_get_all_descriptions(hass)
- assert len(descriptions) == 2
-
+ assert len(descriptions) == 3
+ assert DOMAIN_LOGGER in descriptions
assert descriptions[DOMAIN_LOGGER]["set_default_level"]["name"] == "Translated name"
assert (
descriptions[DOMAIN_LOGGER]["set_default_level"]["description"]
diff --git a/tests/ruff.toml b/tests/ruff.toml
index 5455e211762..87725160751 100644
--- a/tests/ruff.toml
+++ b/tests/ruff.toml
@@ -6,6 +6,7 @@ extend = "../pyproject.toml"
extend-ignore = [
"B904", # Use raise from to specify exception cause
"N815", # Variable {name} in class scope should not be mixedCase
+ "RUF018", # Avoid assignment expressions in assert statements
]
[lint.isort]
diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py
index 8367eda76e8..72bb4dd5b67 100644
--- a/tests/scripts/test_auth.py
+++ b/tests/scripts/test_auth.py
@@ -42,9 +42,7 @@ async def test_list_user(hass: HomeAssistant, provider, capsys) -> None:
captured = capsys.readouterr()
- assert captured.out == "\n".join(
- ["test-user", "second-user", "", "Total users: 2", ""]
- )
+ assert captured.out == "test-user\nsecond-user\n\nTotal users: 2\n"
async def test_add_user(
diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py
index 79c64259f8b..76acb2ff678 100644
--- a/tests/scripts/test_check_config.py
+++ b/tests/scripts/test_check_config.py
@@ -5,6 +5,7 @@ from unittest.mock import patch
import pytest
+from homeassistant.components.http.const import StrictConnectionMode
from homeassistant.config import YAML_CONFIG_FILE
from homeassistant.scripts import check_config
@@ -134,6 +135,7 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None:
"login_attempts_threshold": -1,
"server_port": 8123,
"ssl_profile": "modern",
+ "strict_connection": StrictConnectionMode.DISABLED,
"use_x_frame_options": True,
"server_host": ["0.0.0.0", "::"],
}
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 41796f2f7d2..404858200bc 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -1471,6 +1471,50 @@ async def test_async_get_component_deadlock_fallback(
assert module is module_mock
+async def test_async_get_component_deadlock_fallback_module_not_found(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Verify async_get_component fallback behavior.
+
+ Ensure that fallback is not triggered on ModuleNotFoundError.
+ """
+ executor_import_integration = _get_test_integration(
+ hass, "executor_import", True, import_executor=True
+ )
+ assert executor_import_integration.import_executor is True
+ module_mock = MagicMock(__file__="__init__.py")
+ import_attempts = 0
+
+ def mock_import(module: str, *args: Any, **kwargs: Any) -> Any:
+ nonlocal import_attempts
+ if module == "homeassistant.components.executor_import":
+ import_attempts += 1
+
+ if import_attempts == 1:
+ raise ModuleNotFoundError(
+ "homeassistant.components.executor_import not found",
+ name="homeassistant.components.executor_import",
+ )
+
+ return module_mock
+
+ assert "homeassistant.components.executor_import" not in sys.modules
+ assert "custom_components.executor_import" not in sys.modules
+ with (
+ patch("homeassistant.loader.importlib.import_module", mock_import),
+ pytest.raises(
+ ModuleNotFoundError, match="homeassistant.components.executor_import"
+ ),
+ ):
+ await executor_import_integration.async_get_component()
+
+ # We should not have tried to fall back to the event loop import
+ assert "loaded_executor=False" not in caplog.text
+ assert "homeassistant.components.executor_import" not in sys.modules
+ assert "custom_components.executor_import" not in sys.modules
+ assert import_attempts == 1
+
+
async def test_async_get_component_raises_after_import_failure(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
@@ -1551,6 +1595,52 @@ async def test_async_get_platform_deadlock_fallback(
assert module is module_mock
+async def test_async_get_platform_deadlock_fallback_module_not_found(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Verify async_get_platform fallback behavior.
+
+ Ensure that fallback is not triggered on ModuleNotFoundError.
+ """
+ executor_import_integration = _get_test_integration(
+ hass, "executor_import", True, import_executor=True
+ )
+ assert executor_import_integration.import_executor is True
+ module_mock = MagicMock()
+ import_attempts = 0
+
+ def mock_import(module: str, *args: Any, **kwargs: Any) -> Any:
+ nonlocal import_attempts
+ if module == "homeassistant.components.executor_import.config_flow":
+ import_attempts += 1
+
+ if import_attempts == 1:
+ raise ModuleNotFoundError(
+ "Not found homeassistant.components.executor_import.config_flow",
+ name="homeassistant.components.executor_import.config_flow",
+ )
+
+ return module_mock
+
+ assert "homeassistant.components.executor_import" not in sys.modules
+ assert "custom_components.executor_import" not in sys.modules
+ with (
+ patch("homeassistant.loader.importlib.import_module", mock_import),
+ pytest.raises(
+ ModuleNotFoundError,
+ match="homeassistant.components.executor_import.config_flow",
+ ),
+ ):
+ await executor_import_integration.async_get_platform("config_flow")
+
+ # We should not have tried to fall back to the event loop import
+ assert "executor=['config_flow']" in caplog.text
+ assert "loop=['config_flow']" not in caplog.text
+ assert "homeassistant.components.executor_import" not in sys.modules
+ assert "custom_components.executor_import" not in sys.modules
+ assert import_attempts == 1
+
+
async def test_async_get_platform_raises_after_import_failure(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None: