From 990ecbba72243323ab9b197677c18911bb3213eb Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+j-stienstra@users.noreply.github.com> Date: Sun, 12 Mar 2023 19:31:10 +0100 Subject: [PATCH 0001/1165] Recode Home Assistant instance name to ascii for Jellyfin (#87368) Recode instance name to ascii --- homeassistant/components/jellyfin/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 39085317a5..95038d54f5 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -20,10 +20,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_data[CONF_CLIENT_DEVICE_ID] = entry.entry_id hass.config_entries.async_update_entry(entry, data=entry_data) - client = create_client( - device_id=entry.data[CONF_CLIENT_DEVICE_ID], - device_name=hass.config.location_name, - ) + device_id = entry.data[CONF_CLIENT_DEVICE_ID] + device_name = ascii(hass.config.location_name) + + client = create_client(device_id=device_id, device_name=device_name) try: user_id, connect_result = await validate_input(hass, dict(entry.data), client) From 6ebd493c4d98b8a1462d277f2edf8f58b90cb9ce Mon Sep 17 00:00:00 2001 From: Arjan <44190435+vingerha@users.noreply.github.com> Date: Mon, 13 Mar 2023 11:57:49 +0100 Subject: [PATCH 0002/1165] Fix gtfs with 2023.3 (sqlachemy update) (#89175) --- homeassistant/components/gtfs/sensor.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 3a79d8d88a..6cf1a6d460 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -342,12 +342,14 @@ def get_next_departure( origin_stop_time.departure_time LIMIT :limit """ - result = schedule.engine.execute( + result = schedule.engine.connect().execute( text(sql_query), - origin_station_id=start_station_id, - end_station_id=end_station_id, - today=now_date, - limit=limit, + { + "origin_station_id": start_station_id, + "end_station_id": end_station_id, + "today": now_date, + "limit": limit, + }, ) # Create lookup timetable for today and possibly tomorrow, taking into @@ -357,7 +359,8 @@ def get_next_departure( yesterday_start = today_start = tomorrow_start = None yesterday_last = today_last = "" - for row in result: + for row_cursor in result: + row = row_cursor._asdict() if row["yesterday"] == 1 and yesterday_date >= row["start_date"]: extras = {"day": "yesterday", "first": None, "last": False} if yesterday_start is None: @@ -800,7 +803,10 @@ class GTFSDepartureSensor(SensorEntity): @staticmethod def dict_for_table(resource: Any) -> dict: """Return a dictionary for the SQLAlchemy resource given.""" - return {col: getattr(resource, col) for col in resource.__table__.columns} + _dict = {} + for column in resource.__table__.columns: + _dict[column.name] = str(getattr(resource, column.name)) + return _dict def append_keys(self, resource: dict, prefix: str | None = None) -> None: """Properly format key val pairs to append to attributes.""" From 8c2569d2cebee3fc794a87ac7bc30fa361274c5b Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sat, 11 Mar 2023 14:27:33 -0500 Subject: [PATCH 0003/1165] Reconnect on any ScreenLogic exception (#89269) Co-authored-by: J. Nick Koston --- homeassistant/components/screenlogic/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index fad4dc6509..ad2f9c64f3 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -159,11 +159,9 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): """Fetch data from the Screenlogic gateway.""" try: await self._async_update_configured_data() - except ScreenLogicError as error: - _LOGGER.warning("Update error - attempting reconnect: %s", error) + except (ScreenLogicError, ScreenLogicWarning) as ex: + _LOGGER.warning("Update error - attempting reconnect: %s", ex) await self._async_reconnect_update_data() - except ScreenLogicWarning as warn: - raise UpdateFailed(f"Incomplete update: {warn}") from warn return None From 7cb462067106ddd914abaf57018c72b1a1930098 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Mar 2023 16:03:41 -1000 Subject: [PATCH 0004/1165] Fix data migration never finishing when database has invalid datetimes (#89474) * Fix data migration never finishing when database has invalid datetimes If there were impossible datetime values in the database (likely from a manual sqlite to MySQL conversion) the conversion would never complete * Update homeassistant/components/recorder/migration.py --- homeassistant/components/recorder/migration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0b8fe9243b..838cb181d0 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1072,7 +1072,7 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE events set time_fired_ts=" - "IF(time_fired is NULL,0," + "IF(time_fired is NULL or UNIX_TIMESTAMP(time_fired) is NULL,0," "UNIX_TIMESTAMP(time_fired)" ") " "where time_fired_ts is NULL " @@ -1085,7 +1085,7 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE states set last_updated_ts=" - "IF(last_updated is NULL,0," + "IF(last_updated is NULL or UNIX_TIMESTAMP(last_updated) is NULL,0," "UNIX_TIMESTAMP(last_updated) " "), " "last_changed_ts=" @@ -1161,7 +1161,7 @@ def _migrate_statistics_columns_to_timestamp( result = session.connection().execute( text( f"UPDATE {table} set start_ts=" - "IF(start is NULL,0," + "IF(start is NULL or UNIX_TIMESTAMP(start) is NULL,0," "UNIX_TIMESTAMP(start) " "), " "created_ts=" From 1e7f58d85947b58c3f0b98b3efdf25ce33c9ea57 Mon Sep 17 00:00:00 2001 From: rappenze Date: Sat, 11 Mar 2023 09:15:05 +0100 Subject: [PATCH 0005/1165] Fix bug in fibaro cover (#89502) --- homeassistant/components/fibaro/cover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index e19c5c32e8..c73c45d254 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -94,9 +94,9 @@ class FibaroCover(FibaroDevice, CoverEntity): """Return if the cover is closed.""" if self._is_open_close_only(): state = self.fibaro_device.state - if not state.has_value or state.str_value.lower() == "unknown": + if not state.has_value or state.str_value().lower() == "unknown": return None - return state.str_value.lower() == "closed" + return state.str_value().lower() == "closed" if self.current_cover_position is None: return None From 950a1f6e9e8348b28131ccc756658b61ae51e285 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 11 Mar 2023 20:13:27 +0100 Subject: [PATCH 0006/1165] Bump pydeconz to v110 (#89527) * Bump pydeconz to v109 * Bump pydeconz to v110 for additional color modes --- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index b0ad90857b..5569f9d5e8 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==108"], + "requirements": ["pydeconz==110"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index 33e6d70f7e..e67ca68c3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1573,7 +1573,7 @@ pydaikin==2.9.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==108 +pydeconz==110 # homeassistant.components.delijn pydelijn==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2f1b0f751..bbc2053d1f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1134,7 +1134,7 @@ pycoolmasternet-async==0.1.5 pydaikin==2.9.0 # homeassistant.components.deconz -pydeconz==108 +pydeconz==110 # homeassistant.components.dexcom pydexcom==0.2.3 From ca0304ffc4e227b3048c053614c691ae37319c4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 10:37:00 -1000 Subject: [PATCH 0007/1165] Fix get_significant_states_with_session query looking at legacy columns (#89558) --- homeassistant/components/recorder/history.py | 8 +++++--- tests/components/recorder/test_history.py | 21 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index b67790f9a4..a745716757 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -282,9 +282,11 @@ def _significant_states_stmt( (States.last_changed_ts == States.last_updated_ts) | States.last_changed_ts.is_(None) ) - stmt += lambda q: q.filter( - (States.last_changed == States.last_updated) | States.last_changed.is_(None) - ) + else: + stmt += lambda q: q.filter( + (States.last_changed == States.last_updated) + | States.last_changed.is_(None) + ) elif significant_changes_only: if schema_version >= 31: stmt += lambda q: q.filter( diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index d082806f3d..2b4bed072a 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -209,6 +209,27 @@ def test_significant_states_with_session_entity_minimal_response_no_matches( ) +def test_significant_states_with_session_single_entity( + hass_recorder: Callable[..., HomeAssistant], +) -> None: + """Test get_significant_states_with_session with a single entity.""" + hass = hass_recorder() + hass.states.set("demo.id", "any", {"attr": True}) + hass.states.set("demo.id", "any2", {"attr": True}) + wait_recording_done(hass) + now = dt_util.utcnow() + with session_scope(hass=hass) as session: + states = history.get_significant_states_with_session( + hass, + session, + now - timedelta(days=1), + now, + entity_ids=["demo.id"], + minimal_response=False, + ) + assert len(states["demo.id"]) == 2 + + @pytest.mark.parametrize( ("attributes", "no_attributes", "limit"), [ From 22922da60737c05122d641d56df2e11424abebba Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Mon, 13 Mar 2023 09:07:10 +0100 Subject: [PATCH 0008/1165] Bump aiopvpc to 4.1.0 (#89593) --- homeassistant/components/pvpc_hourly_pricing/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index 89520f079a..64e6e19086 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["aiopvpc"], "quality_scale": "platinum", - "requirements": ["aiopvpc==4.0.1"] + "requirements": ["aiopvpc==4.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e67ca68c3d..b387aac197 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -241,7 +241,7 @@ aiopurpleair==2022.12.1 aiopvapi==2.0.4 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.0.1 +aiopvpc==4.1.0 # homeassistant.components.lidarr # homeassistant.components.radarr diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbc2053d1f..ef9c6d1916 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -219,7 +219,7 @@ aiopurpleair==2022.12.1 aiopvapi==2.0.4 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.0.1 +aiopvpc==4.1.0 # homeassistant.components.lidarr # homeassistant.components.radarr From 65c614421adaea5e20a28ed40dbe19dc414213c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 16:57:22 -1000 Subject: [PATCH 0009/1165] Increase maximum aiohttp connections to 4096 (#89611) fixes #89408 --- homeassistant/helpers/aiohttp_client.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index d623de5e81..3ab583096c 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -39,6 +39,20 @@ SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format( WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session" +# +# The default connection limit of 100 meant that you could only have +# 100 concurrent connections. +# +# This was effectively a limit of 100 devices and than +# the supervisor API would fail as soon as it was hit. +# +# We now apply the 100 limit per host, so that we can have 100 connections +# to a single host, but can have more than 4096 connections in total to +# prevent a single host from using all available connections. +# +MAXIMUM_CONNECTIONS = 4096 +MAXIMUM_CONNECTIONS_PER_HOST = 100 + class HassClientResponse(aiohttp.ClientResponse): """aiohttp.ClientResponse with a json method that uses json_loads by default.""" @@ -261,7 +275,12 @@ def _async_get_connector( else: ssl_context = False - connector = aiohttp.TCPConnector(enable_cleanup_closed=True, ssl=ssl_context) + connector = aiohttp.TCPConnector( + enable_cleanup_closed=True, + ssl=ssl_context, + limit=MAXIMUM_CONNECTIONS, + limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, + ) hass.data[key] = connector async def _async_close_connector(event: Event) -> None: From d2f90236d11ff32c7c7f7eced4e03697967f3444 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 09:44:20 +0100 Subject: [PATCH 0010/1165] Rename modules named repairs.py which are not repairs platforms (#89618) --- .../components/bayesian/binary_sensor.py | 2 +- .../bayesian/{repairs.py => issues.py} | 2 +- homeassistant/components/hassio/__init__.py | 10 ++++----- .../hassio/{repairs.py => issues.py} | 18 +++++++-------- tests/components/hassio/conftest.py | 2 +- .../{test_repairs.py => test_issues.py} | 22 +++++++++---------- 6 files changed, 28 insertions(+), 28 deletions(-) rename homeassistant/components/bayesian/{repairs.py => issues.py} (97%) rename homeassistant/components/hassio/{repairs.py => issues.py} (92%) rename tests/components/hassio/{test_repairs.py => test_issues.py} (96%) diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 77571e1a80..06baef1bd0 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -60,7 +60,7 @@ from .const import ( DEFAULT_PROBABILITY_THRESHOLD, ) from .helpers import Observation -from .repairs import raise_mirrored_entries, raise_no_prob_given_false +from .issues import raise_mirrored_entries, raise_no_prob_given_false _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bayesian/repairs.py b/homeassistant/components/bayesian/issues.py similarity index 97% rename from homeassistant/components/bayesian/repairs.py rename to homeassistant/components/bayesian/issues.py index 9a52763694..0f97b86b0e 100644 --- a/homeassistant/components/bayesian/repairs.py +++ b/homeassistant/components/bayesian/issues.py @@ -1,4 +1,4 @@ -"""Helpers for generating repairs.""" +"""Helpers for generating issues.""" from __future__ import annotations from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4f5d8e9d31..3ea42e331d 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -96,7 +96,7 @@ from .handler import ( # noqa: F401 ) from .http import HassIOView from .ingress import async_setup_ingress_view -from .repairs import SupervisorRepairs +from .issues import SupervisorIssues from .websocket_api import async_load_websocket_api _LOGGER = logging.getLogger(__name__) @@ -123,7 +123,7 @@ DATA_SUPERVISOR_INFO = "hassio_supervisor_info" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" -DATA_SUPERVISOR_REPAIRS = "supervisor_repairs" +DATA_SUPERVISOR_ISSUES = "supervisor_issues" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) ADDONS_COORDINATOR = "hassio_addons_coordinator" @@ -581,9 +581,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) ) - # Start listening for problems with supervisor and making repairs - hass.data[DATA_SUPERVISOR_REPAIRS] = repairs = SupervisorRepairs(hass, hassio) - await repairs.setup() + # Start listening for problems with supervisor and making issues + hass.data[DATA_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio) + await issues.setup() return True diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/issues.py similarity index 92% rename from homeassistant/components/hassio/repairs.py rename to homeassistant/components/hassio/issues.py index 21120d8d52..a0d51c4806 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/issues.py @@ -70,11 +70,11 @@ UNHEALTHY_REASONS = { } -class SupervisorRepairs: - """Create repairs from supervisor events.""" +class SupervisorIssues: + """Create issues from supervisor events.""" def __init__(self, hass: HomeAssistant, client: HassIO) -> None: - """Initialize supervisor repairs.""" + """Initialize supervisor issues.""" self._hass = hass self._client = client self._unsupported_reasons: set[str] = set() @@ -87,7 +87,7 @@ class SupervisorRepairs: @unhealthy_reasons.setter def unhealthy_reasons(self, reasons: set[str]) -> None: - """Set unhealthy reasons. Create or delete repairs as necessary.""" + """Set unhealthy reasons. Create or delete issues as necessary.""" for unhealthy in reasons - self.unhealthy_reasons: if unhealthy in UNHEALTHY_REASONS: translation_key = f"unhealthy_{unhealthy}" @@ -119,7 +119,7 @@ class SupervisorRepairs: @unsupported_reasons.setter def unsupported_reasons(self, reasons: set[str]) -> None: - """Set unsupported reasons. Create or delete repairs as necessary.""" + """Set unsupported reasons. Create or delete issues as necessary.""" for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons: if unsupported in UNSUPPORTED_REASONS: translation_key = f"unsupported_{unsupported}" @@ -149,18 +149,18 @@ class SupervisorRepairs: await self.update() async_dispatcher_connect( - self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_repairs + self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues ) async def update(self) -> None: - """Update repairs from Supervisor resolution center.""" + """Update issuess from Supervisor resolution center.""" data = await self._client.get_resolution_info() self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) @callback - def _supervisor_events_to_repairs(self, event: dict[str, Any]) -> None: - """Create repairs from supervisor events.""" + def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: + """Create issues from supervisor events.""" if ATTR_WS_EVENT not in event: return diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 78ae9643d6..afe641405e 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -52,7 +52,7 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): "homeassistant.components.hassio.HassIO.get_ingress_panels", return_value={"panels": []}, ), patch( - "homeassistant.components.hassio.repairs.SupervisorRepairs.setup" + "homeassistant.components.hassio.issues.SupervisorIssues.setup" ), patch( "homeassistant.components.hassio.HassIO.refresh_updates" ): diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_issues.py similarity index 96% rename from tests/components/hassio/test_repairs.py rename to tests/components/hassio/test_issues.py index 8806e641a5..5b280d0c82 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_issues.py @@ -1,4 +1,4 @@ -"""Test repairs from supervisor issues.""" +"""Test issues from supervisor issues.""" from __future__ import annotations import os @@ -145,12 +145,12 @@ def assert_repair_in_list(issues: list[dict[str, Any]], unhealthy: bool, reason: } in issues -async def test_unhealthy_repairs( +async def test_unhealthy_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test repairs added for unhealthy systems.""" + """Test issues added for unhealthy systems.""" mock_resolution_info(aioclient_mock, unhealthy=["docker", "setup"]) result = await async_setup_component(hass, "hassio", {}) @@ -166,12 +166,12 @@ async def test_unhealthy_repairs( assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="setup") -async def test_unsupported_repairs( +async def test_unsupported_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test repairs added for unsupported systems.""" + """Test issues added for unsupported systems.""" mock_resolution_info(aioclient_mock, unsupported=["content_trust", "os"]) result = await async_setup_component(hass, "hassio", {}) @@ -189,12 +189,12 @@ async def test_unsupported_repairs( assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") -async def test_unhealthy_repairs_add_remove( +async def test_unhealthy_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test unhealthy repairs added and removed from dispatches.""" + """Test unhealthy issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) result = await async_setup_component(hass, "hassio", {}) @@ -245,12 +245,12 @@ async def test_unhealthy_repairs_add_remove( assert msg["result"] == {"issues": []} -async def test_unsupported_repairs_add_remove( +async def test_unsupported_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test unsupported repairs added and removed from dispatches.""" + """Test unsupported issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) result = await async_setup_component(hass, "hassio", {}) @@ -301,12 +301,12 @@ async def test_unsupported_repairs_add_remove( assert msg["result"] == {"issues": []} -async def test_reset_repairs_supervisor_restart( +async def test_reset_issues_supervisor_restart( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Unsupported/unhealthy repairs reset on supervisor restart.""" + """Unsupported/unhealthy issues reset on supervisor restart.""" mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) result = await async_setup_component(hass, "hassio", {}) From f5562e93ac41e6a264fb5fdb8ac7c4299565a649 Mon Sep 17 00:00:00 2001 From: tomrennen Date: Mon, 13 Mar 2023 15:15:13 +0100 Subject: [PATCH 0011/1165] Improved "ON" state check for `Use room sensor for cooling` (#89634) --- homeassistant/components/nibe_heatpump/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 9c7d8641b6..a68aabacf4 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -139,7 +139,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): mode = HVACMode.OFF if _get_value(self._coil_use_room_sensor) == "ON": - if _get_value(self._coil_cooling_with_room_sensor) == "ON": + if _get_value(self._coil_cooling_with_room_sensor) != "OFF": mode = HVACMode.HEAT_COOL else: mode = HVACMode.HEAT From daa5718a80e3a3d84c59a50836bea73c594da29a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 13 Mar 2023 13:26:50 -0400 Subject: [PATCH 0012/1165] Bumped version to 2023.3.4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 82b9fd1a31..040794ed21 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 0dcc14344a..eed3a9b967 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.3" +version = "2023.3.4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 160518350fdf9601374d34d97b9d6bdf71d834bf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 13 Mar 2023 14:51:01 -0400 Subject: [PATCH 0013/1165] Bump SQLAlchemy to 2.0.6 (#89650) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index ed885127b1..4f87c19ca7 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.5.post1", "fnvhash==0.1.0"] + "requirements": ["sqlalchemy==2.0.6", "fnvhash==0.1.0"] } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index bdedbb9b20..7513bbd8c7 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.5.post1"] + "requirements": ["sqlalchemy==2.0.6"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0d3be634e7..e290d4730d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -42,7 +42,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.2 scapy==2.5.0 -sqlalchemy==2.0.5.post1 +sqlalchemy==2.0.6 typing-extensions>=4.5.0,<5.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index b387aac197..d8280f48dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2398,7 +2398,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.5.post1 +sqlalchemy==2.0.6 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef9c6d1916..b788f40b39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1698,7 +1698,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.5.post1 +sqlalchemy==2.0.6 # homeassistant.components.srp_energy srpenergy==1.3.6 From 69582b7ecbced2cb0a07cb72b50c29c99d99382e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 13 Mar 2023 20:52:01 -0400 Subject: [PATCH 0014/1165] Bump ZHA dependencies (#89667) * Bump `zha-quirks` library and account for `setup_quirks` signature * Bump other ZHA dependencies * Revert zigpy bump --- homeassistant/components/zha/__init__.py | 2 +- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index d0496fe7b6..dd07d4da42 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -107,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b zha_data.setdefault(platform, []) if config.get(CONF_ENABLE_QUIRKS, True): - setup_quirks(config) + setup_quirks(custom_quirks_path=config.get(CONF_CUSTOM_QUIRKS_PATH)) # temporary code to remove the ZHA storage file from disk. # this will be removed in 2022.10.0 diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 44f88aa733..3061d867b6 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,10 +20,10 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.9", + "bellows==0.34.10", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.93", + "zha-quirks==0.0.94", "zigpy-deconz==0.19.2", "zigpy==0.53.2", "zigpy-xbee==0.16.2", diff --git a/requirements_all.txt b/requirements_all.txt index d8280f48dd..38c2675815 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.34.9 +bellows==0.34.10 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.1 @@ -2706,7 +2706,7 @@ zeroconf==0.47.3 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.93 +zha-quirks==0.0.94 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b788f40b39..72fb11f485 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -352,7 +352,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.34.9 +bellows==0.34.10 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.1 @@ -1922,7 +1922,7 @@ zeroconf==0.47.3 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.93 +zha-quirks==0.0.94 # homeassistant.components.zha zigpy-deconz==0.19.2 From fdd9c5383f8f7c95626c163191f60f42ef1f5212 Mon Sep 17 00:00:00 2001 From: zhangshengdong29 <435878393@qq.com> Date: Thu, 16 Mar 2023 02:13:32 +0800 Subject: [PATCH 0015/1165] ArestData does not have available (#88631) --- homeassistant/components/arest/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 5c95fd63c3..2e6012e0e6 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -180,7 +180,7 @@ class ArestData: self._resource = resource self._pin = pin self.data = {} - self._attr_available = True + self.available = True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -201,7 +201,7 @@ class ArestData: f"{self._resource}/digital/{self._pin}", timeout=10 ) self.data = {"value": response.json()["return_value"]} - self._attr_available = True + self.available = True except requests.exceptions.ConnectionError: _LOGGER.error("No route to device %s", self._resource) - self._attr_available = False + self.available = False From a9a6ff50ccfb228b07bb242a253a2a1057eaba5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 14:04:10 -1000 Subject: [PATCH 0016/1165] Bump aioesphomeapi to 13.5.0 (#89262) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e8e4e4876f..54cdc23355 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.4.2", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.5.0", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 38c2675815..c657f0202a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.2 +aioesphomeapi==13.5.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72fb11f485..b930f465c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.2 +aioesphomeapi==13.5.0 # homeassistant.components.flo aioflo==2021.11.0 From 02738fb9d4cced2886085f559f9f21eae72e1889 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 16 Mar 2023 13:26:56 +0100 Subject: [PATCH 0017/1165] Handle int or mapping for off case in nibe cooling (#89680) Handle int or mapping for off case in nibe --- homeassistant/components/nibe_heatpump/climate.py | 10 +++++++--- homeassistant/components/nibe_heatpump/const.py | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index a68aabacf4..0df787de98 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -31,6 +31,7 @@ from . import Coordinator from .const import ( DOMAIN, LOGGER, + VALUES_COOL_WITH_ROOM_SENSOR_OFF, VALUES_MIXING_VALVE_CLOSED_STATE, VALUES_PRIORITY_COOLING, VALUES_PRIORITY_HEATING, @@ -139,10 +140,13 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): mode = HVACMode.OFF if _get_value(self._coil_use_room_sensor) == "ON": - if _get_value(self._coil_cooling_with_room_sensor) != "OFF": - mode = HVACMode.HEAT_COOL - else: + if ( + _get_value(self._coil_cooling_with_room_sensor) + in VALUES_COOL_WITH_ROOM_SENSOR_OFF + ): mode = HVACMode.HEAT + else: + mode = HVACMode.HEAT_COOL self._attr_hvac_mode = mode setpoint_heat = _get_float(self._coil_setpoint_heat) diff --git a/homeassistant/components/nibe_heatpump/const.py b/homeassistant/components/nibe_heatpump/const.py index 7d9bf58709..dc6b4b1899 100644 --- a/homeassistant/components/nibe_heatpump/const.py +++ b/homeassistant/components/nibe_heatpump/const.py @@ -17,3 +17,4 @@ CONF_MODBUS_UNIT = "modbus_unit" VALUES_MIXING_VALVE_CLOSED_STATE = (30, "CLOSED", "SHUNT CLOSED") VALUES_PRIORITY_HEATING = (30, "HEAT") VALUES_PRIORITY_COOLING = (60, "COOLING") +VALUES_COOL_WITH_ROOM_SENSOR_OFF = (0, "OFF") From f120bac17f75724a8a9dc3005ca07b672baa741c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 16 Mar 2023 04:48:00 +0100 Subject: [PATCH 0018/1165] Secure modbus hub_collect remains valid (#89684) Secure hub_collect remains valid. --- homeassistant/components/modbus/modbus.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 627950fe00..c40a05c1ef 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -137,8 +137,10 @@ async def async_modbus_setup( for name in hubs: if not await hubs[name].async_setup(): return False + hub_collect = hass.data[DOMAIN] + else: + hass.data[DOMAIN] = hub_collect = {} - hass.data[DOMAIN] = hub_collect = {} for conf_hub in config[DOMAIN]: my_hub = ModbusHub(hass, conf_hub) hub_collect[conf_hub[CONF_NAME]] = my_hub From c3d7696c2e41fe4f55860c26bdcf3c75a67694aa Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 15 Mar 2023 06:01:34 +0100 Subject: [PATCH 0019/1165] Update to nibe 2.1.4 (#89686) --- homeassistant/components/nibe_heatpump/config_flow.py | 1 + homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/config_flow.py b/homeassistant/components/nibe_heatpump/config_flow.py index 434a9a50ea..6680ca6e32 100644 --- a/homeassistant/components/nibe_heatpump/config_flow.py +++ b/homeassistant/components/nibe_heatpump/config_flow.py @@ -89,6 +89,7 @@ async def validate_nibegw_input( """Validate the user input allows us to connect.""" heatpump = HeatPump(Model[data[CONF_MODEL]]) + heatpump.word_swap = True await heatpump.initialize() connection = NibeGW( diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 5114cc222e..81c23437bb 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.0.0"] + "requirements": ["nibe==2.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index c657f0202a..4db6706449 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1201,7 +1201,7 @@ nextcord==2.0.0a8 nextdns==1.3.0 # homeassistant.components.nibe_heatpump -nibe==2.0.0 +nibe==2.1.4 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b930f465c3..5f79cfb759 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -891,7 +891,7 @@ nextcord==2.0.0a8 nextdns==1.3.0 # homeassistant.components.nibe_heatpump -nibe==2.0.0 +nibe==2.1.4 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 52981699cf3348a38fef0df7bd14705d1c52096c Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Tue, 14 Mar 2023 15:44:55 -0300 Subject: [PATCH 0020/1165] Bump bond-async to 0.1.23 (#89697) --- homeassistant/components/bond/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index bf343673fd..fc91f8eb72 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["bond_async"], "quality_scale": "platinum", - "requirements": ["bond-async==0.1.22"], + "requirements": ["bond-async==0.1.23"], "zeroconf": ["_bond._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 4db6706449..0b8a7d2207 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -467,7 +467,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 # homeassistant.components.bond -bond-async==0.1.22 +bond-async==0.1.23 # homeassistant.components.bosch_shc boschshcpy==0.2.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f79cfb759..bd4f88b7a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -384,7 +384,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 # homeassistant.components.bond -bond-async==0.1.22 +bond-async==0.1.23 # homeassistant.components.bosch_shc boschshcpy==0.2.35 From 90a4afb6faa358bf96eba12f6ae29dc746fa907f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 15 Mar 2023 12:27:45 +0100 Subject: [PATCH 0021/1165] Correct modbus serial method parameter (#89738) --- homeassistant/components/modbus/modbus.py | 9 +++++++-- tests/components/modbus/test_init.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index c40a05c1ef..b53cfda104 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -16,7 +16,7 @@ from pymodbus.client import ( from pymodbus.constants import Defaults from pymodbus.exceptions import ModbusException from pymodbus.pdu import ModbusResponse -from pymodbus.transaction import ModbusRtuFramer +from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer import voluptuous as vol from homeassistant.const import ( @@ -281,9 +281,12 @@ class ModbusHub: } if self._config_type == SERIAL: # serial configuration + if client_config[CONF_METHOD] == "ascii": + self._pb_params["framer"] = ModbusAsciiFramer + else: + self._pb_params["framer"] = ModbusRtuFramer self._pb_params.update( { - "method": client_config[CONF_METHOD], "baudrate": client_config[CONF_BAUDRATE], "stopbits": client_config[CONF_STOPBITS], "bytesize": client_config[CONF_BYTESIZE], @@ -295,6 +298,8 @@ class ModbusHub: self._pb_params["host"] = client_config[CONF_HOST] if self._config_type == RTUOVERTCP: self._pb_params["framer"] = ModbusRtuFramer + else: + self._pb_params["framer"] = ModbusSocketFramer Defaults.Timeout = client_config[CONF_TIMEOUT] if CONF_MSG_WAIT in client_config: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 75f2f9d3e6..7a06923404 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -378,7 +378,7 @@ async def test_duplicate_entity_validator(do_config) -> None: CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, - CONF_METHOD: "rtu", + CONF_METHOD: "ascii", CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, From abda7b8a5bf95fcdb787decd1bc9541c8424f158 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 15 Mar 2023 21:22:13 +0100 Subject: [PATCH 0022/1165] Fix imap server push holding HA startup (#89750) --- homeassistant/components/imap/coordinator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 8a716fe478..e170f79e7f 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -77,7 +77,9 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]): f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) if self.support_push: - self.hass.async_create_task(self.async_wait_server_push()) + self.hass.async_create_background_task( + self.async_wait_server_push(), "Wait for IMAP data push" + ) return len(lines[0].split()) async def async_wait_server_push(self) -> None: @@ -100,5 +102,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]): async def shutdown(self, *_) -> None: """Close resources.""" if self.imap_client: + if self.imap_client.has_pending_idle(): + self.imap_client.idle_done() await self.imap_client.stop_wait_server_push() await self.imap_client.logout() From 9fa73fe3a9218b66905c9ffb91ba8cc3c3b3e7c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 20:00:47 -1000 Subject: [PATCH 0023/1165] Bump aioesphomeapi to 13.5.1 (#89777) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 54cdc23355..95b6c091d5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.5.0", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.5.1", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b8a7d2207..9bda92bc7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.0 +aioesphomeapi==13.5.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd4f88b7a4..7e16fea877 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.0 +aioesphomeapi==13.5.1 # homeassistant.components.flo aioflo==2021.11.0 From e651ca747bc5ea34920397dbe7aa347a3da38e77 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 16 Mar 2023 18:32:07 +0100 Subject: [PATCH 0024/1165] Update frontend to 20230309.1 (#89802) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a4d97201c5..2c13e81ee3 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==20230309.0"] + "requirements": ["home-assistant-frontend==20230309.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e290d4730d..39b1a1b3dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ fnvhash==0.1.0 hass-nabucasa==0.61.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230309.0 +home-assistant-frontend==20230309.1 home-assistant-intents==2023.2.28 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 9bda92bc7f..cdffe860f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230309.0 +home-assistant-frontend==20230309.1 # homeassistant.components.conversation home-assistant-intents==2023.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e16fea877..c8f2b57a76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230309.0 +home-assistant-frontend==20230309.1 # homeassistant.components.conversation home-assistant-intents==2023.2.28 From 7e18e15cacc4bd00db1165de995f2b1c2ce68565 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 18:48:17 +0100 Subject: [PATCH 0025/1165] Bumped version to 2023.3.5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 040794ed21..f8e7859bf4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "4" +PATCH_VERSION: Final = "5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index eed3a9b967..a9c70089c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.4" +version = "2023.3.5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From d25e3943105b4b1aea985633e1175168c4a04ac9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 22 Mar 2023 09:18:09 +0100 Subject: [PATCH 0026/1165] Implement data update coordinator for nextcloud (#89652) * implement data update coordinator * apply suggestions * apply suggestions --- .../components/nextcloud/__init__.py | 57 +++------------ .../components/nextcloud/binary_sensor.py | 18 +++-- .../components/nextcloud/coordinator.py | 73 +++++++++++++++++++ homeassistant/components/nextcloud/entity.py | 27 +++---- homeassistant/components/nextcloud/sensor.py | 18 +++-- 5 files changed, 121 insertions(+), 72 deletions(-) create mode 100644 homeassistant/components/nextcloud/coordinator.py diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index b4080dd2a1..5dffcbf9fb 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -13,10 +13,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -40,61 +40,28 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Nextcloud integration.""" - # Fetch Nextcloud Monitor api data conf = config[DOMAIN] try: - ncm = NextcloudMonitor(conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD]) + ncm = await hass.async_add_executor_job( + NextcloudMonitor, conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD] + ) except NextcloudMonitorError: _LOGGER.error("Nextcloud setup failed - Check configuration") return False - hass.data[DOMAIN] = get_data_points(ncm.data) - hass.data[DOMAIN]["instance"] = conf[CONF_URL] + coordinator = NextcloudDataUpdateCoordinator( + hass, + ncm, + conf, + ) + hass.data[DOMAIN] = coordinator - def nextcloud_update(event_time): - """Update data from nextcloud api.""" - try: - ncm.update() - except NextcloudMonitorError: - _LOGGER.error("Nextcloud update failed") - return False - - hass.data[DOMAIN] = get_data_points(ncm.data) - hass.data[DOMAIN]["instance"] = conf[CONF_URL] - - # Update sensors on time interval - track_time_interval(hass, nextcloud_update, conf[CONF_SCAN_INTERVAL]) + await coordinator.async_config_entry_first_refresh() for platform in PLATFORMS: discovery.load_platform(hass, platform, DOMAIN, {}, config) return True - - -# Use recursion to create list of sensors & values based on nextcloud api data -def get_data_points(api_data, key_path="", leaf=False): - """Use Recursion to discover data-points and values. - - Get dictionary of data-points by recursing through dict returned by api until - the dictionary value does not contain another dictionary and use the - resulting path of dictionary keys and resulting value as the name/value - for the data-point. - - returns: dictionary of data-point/values - """ - result = {} - for key, value in api_data.items(): - if isinstance(value, dict): - if leaf: - key_path = f"{key}_" - if not leaf: - key_path += f"{key}_" - leaf = True - result.update(get_data_points(value, key_path, leaf)) - else: - result[f"{DOMAIN}_{key_path}{key}"] = value - leaf = False - return result diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 6e0df919f9..52ddb66007 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -7,6 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator from .entity import NextcloudEntity BINARY_SENSORS = ( @@ -26,11 +27,16 @@ def setup_platform( """Set up the Nextcloud sensors.""" if discovery_info is None: return - binary_sensors = [] - for name in hass.data[DOMAIN]: - if name in BINARY_SENSORS: - binary_sensors.append(NextcloudBinarySensor(name)) - add_entities(binary_sensors, True) + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] + + add_entities( + [ + NextcloudBinarySensor(coordinator, name) + for name in coordinator.data + if name in BINARY_SENSORS + ], + True, + ) class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): @@ -39,4 +45,4 @@ class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._state == "yes" + return self.coordinator.data.get(self.item) == "yes" diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py new file mode 100644 index 0000000000..07dc76d41d --- /dev/null +++ b/homeassistant/components/nextcloud/coordinator.py @@ -0,0 +1,73 @@ +"""Data update coordinator for the Nextcloud integration.""" + +import logging +from typing import Any + +from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError + +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Nextcloud data update coordinator.""" + + def __init__( + self, hass: HomeAssistant, ncm: NextcloudMonitor, config: ConfigType + ) -> None: + """Initialize the Nextcloud coordinator.""" + self.config = config + self.ncm = ncm + self.url = config[CONF_URL] + + super().__init__( + hass, + _LOGGER, + name=self.url, + update_interval=config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + ) + + # Use recursion to create list of sensors & values based on nextcloud api data + def _get_data_points( + self, api_data: dict, key_path: str = "", leaf: bool = False + ) -> dict[str, Any]: + """Use Recursion to discover data-points and values. + + Get dictionary of data-points by recursing through dict returned by api until + the dictionary value does not contain another dictionary and use the + resulting path of dictionary keys and resulting value as the name/value + for the data-point. + + returns: dictionary of data-point/values + """ + result = {} + for key, value in api_data.items(): + if isinstance(value, dict): + if leaf: + key_path = f"{key}_" + if not leaf: + key_path += f"{key}_" + leaf = True + result.update(self._get_data_points(value, key_path, leaf)) + else: + result[f"{DOMAIN}_{key_path}{key}"] = value + leaf = False + return result + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch all Nextcloud data.""" + + def _update_data() -> None: + try: + self.ncm.update() + except NextcloudMonitorError as ex: + raise UpdateFailed from ex + + await self.hass.async_add_executor_job(_update_data) + return self._get_data_points(self.ncm.data) diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index cb066e0fcf..54976351dd 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -1,26 +1,23 @@ """Base entity for the Nextcloud integration.""" -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import StateType - -from .const import DOMAIN -class NextcloudEntity(Entity): +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import NextcloudDataUpdateCoordinator + + +class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): """Base Nextcloud entity.""" _attr_icon = "mdi:cloud" - def __init__(self, item: str) -> None: - """Initialize the Nextcloud entity.""" - self._attr_name = item + def __init__(self, coordinator: NextcloudDataUpdateCoordinator, item: str) -> None: + """Initialize the Nextcloud sensor.""" + super().__init__(coordinator) self.item = item - self._state: StateType = None + self._attr_name = item @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID for this sensor.""" - return f"{self.hass.data[DOMAIN]['instance']}#{self.item}" - - def update(self) -> None: - """Update the sensor.""" - self._state = self.hass.data[DOMAIN][self.item] + return f"{self.coordinator.url}#{self.item}" diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 91d4411b0c..459f22d30e 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -7,6 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator from .entity import NextcloudEntity SENSORS = ( @@ -65,11 +66,16 @@ def setup_platform( """Set up the Nextcloud sensors.""" if discovery_info is None: return - sensors = [] - for name in hass.data[DOMAIN]: - if name in SENSORS: - sensors.append(NextcloudSensor(name)) - add_entities(sensors, True) + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] + + add_entities( + [ + NextcloudSensor(coordinator, name) + for name in coordinator.data + if name in SENSORS + ], + True, + ) class NextcloudSensor(NextcloudEntity, SensorEntity): @@ -78,4 +84,4 @@ class NextcloudSensor(NextcloudEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state for this sensor.""" - return self._state + return self.coordinator.data.get(self.item) From 214286acb93a07e95ae4094ba0499d2aa5c946cf Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 22 Mar 2023 10:23:08 +0100 Subject: [PATCH 0027/1165] Prepare MQTT platorm tests part1 (#90051) * Add help_custom_config * Tests alarm_control_panel * Tests binary_sensor * Only use help_custom_config with iterable options --- .../mqtt/test_alarm_control_panel.py | 323 ++++++++------- tests/components/mqtt/test_binary_sensor.py | 382 ++++++++++-------- tests/components/mqtt/test_common.py | 24 ++ 3 files changed, 410 insertions(+), 319 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index a7e5678a3b..79c06d7a5f 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -35,9 +35,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -204,17 +204,12 @@ async def test_update_state_via_state_topic( assert hass.states.get(entity_id).state == state +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_ignore_update_state_if_unknown_via_state_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test ignoring updates via state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() entity_id = "alarm_control_panel.test" @@ -225,31 +220,25 @@ async def test_ignore_update_state_if_unknown_via_state_topic( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), + (DEFAULT_CONFIG, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_no_code( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when no code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( alarm_control_panel.DOMAIN, @@ -262,31 +251,25 @@ async def test_publish_mqtt_no_code( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_code( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG_CODE, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -318,31 +301,29 @@ async def test_publish_mqtt_with_code( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + ( + DEFAULT_CONFIG_REMOTE_CODE, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "ARM_CUSTOM_BYPASS", + ), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_remote_code( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when remode code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG_REMOTE_CODE, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -365,31 +346,29 @@ async def test_publish_mqtt_with_remote_code( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + ( + DEFAULT_CONFIG_REMOTE_CODE_TEXT, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "ARM_CUSTOM_BYPASS", + ), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_remote_code_text( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - service, - payload, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + service: str, + payload: str, ) -> None: """Test publishing of MQTT messages when remote text code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG_REMOTE_CODE_TEXT, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -412,38 +391,85 @@ async def test_publish_mqtt_with_remote_code_text( @pytest.mark.parametrize( - ("service", "payload", "disable_code"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME", "code_arm_required"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY", "code_arm_required"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT", "code_arm_required"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION", "code_arm_required"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS", "code_arm_required"), - (SERVICE_ALARM_DISARM, "DISARM", "code_disarm_required"), - (SERVICE_ALARM_TRIGGER, "TRIGGER", "code_trigger_required"), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_HOME, + "ARM_HOME", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_AWAY, + "ARM_AWAY", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_NIGHT, + "ARM_NIGHT", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_VACATION, + "ARM_VACATION", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "ARM_CUSTOM_BYPASS", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_disarm_required": False},), + ), + SERVICE_ALARM_DISARM, + "DISARM", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_trigger_required": False},), + ), + SERVICE_ALARM_TRIGGER, + "TRIGGER", + ), ], ) async def test_publish_mqtt_with_code_required_false( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - service, - payload, - disable_code, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + service: str, + payload: str, ) -> None: """Test publishing of MQTT messages when code is configured. code_arm_required = False / code_disarm_required = False / code_trigger_required = False """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN][disable_code] = False - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() # No code provided, should publish await hass.services.async_call( @@ -476,25 +502,29 @@ async def test_publish_mqtt_with_code_required_false( mqtt_mock.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ( + { + "code": "0123", + "command_template": '{"action":"{{ action }}","code":"{{ code }}"}', + }, + ), + ) + ], +) async def test_disarm_publishes_mqtt_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test publishing of MQTT messages while disarmed. When command_template set to output json """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = "0123" - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN][ - "command_template" - ] = '{"action":"{{ action }}","code":"{{ code }}"}' - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_alarm_disarm(hass, "0123") mqtt_mock.async_publish.assert_called_once_with( @@ -502,13 +532,9 @@ async def test_disarm_publishes_mqtt_with_template( ) -async def test_update_state_via_state_topic_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test updating with template_value via state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { alarm_control_panel.DOMAIN: { @@ -523,10 +549,14 @@ async def test_update_state_via_state_topic_template( {% endif %}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_update_state_via_state_topic_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test updating with template_value via state topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert state.state == STATE_UNKNOWN @@ -537,16 +567,19 @@ async def test_update_state_via_state_topic_template( assert state.state == STATE_ALARM_ARMED_AWAY +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ({"code": CODE_NUMBER},) + ) + ], +) async def test_attributes_code_number( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = copy.deepcopy(DEFAULT_CONFIG) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = CODE_NUMBER - - assert await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( @@ -555,16 +588,21 @@ async def test_attributes_code_number( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_REMOTE_CODE, + ({"code": "REMOTE_CODE"},), + ) + ], +) async def test_attributes_remote_code_number( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = copy.deepcopy(DEFAULT_CONFIG_REMOTE_CODE) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = "REMOTE_CODE" - - assert await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( @@ -573,16 +611,19 @@ async def test_attributes_remote_code_number( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ({"code": CODE_TEXT},) + ) + ], +) async def test_attributes_code_text( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = copy.deepcopy(DEFAULT_CONFIG) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = CODE_TEXT - - assert await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 3e224a4136..0d3cd69549 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -19,10 +19,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State, callback -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -74,15 +75,9 @@ def binary_sensor_platform_only(): yield -async def test_setting_sensor_value_expires_availability_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -93,10 +88,16 @@ async def test_setting_sensor_value_expires_availability_topic( "availability_topic": "availability-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires_availability_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE @@ -110,15 +111,9 @@ async def test_setting_sensor_value_expires_availability_topic( await expires_helper(hass) -async def test_setting_sensor_value_expires( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -128,10 +123,16 @@ async def test_setting_sensor_value_expires( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() # State should be unavailable since expire_after is defined and > 0 state = hass.states.get("binary_sensor.test") @@ -274,13 +275,9 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( assert state.state == STATE_UNAVAILABLE -async def test_setting_sensor_value_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -290,10 +287,14 @@ async def test_setting_sensor_value_via_mqtt_message( "payload_off": "OFF", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") @@ -312,15 +313,9 @@ async def test_setting_sensor_value_via_mqtt_message( assert state.state == STATE_UNKNOWN -async def test_invalid_sensor_value_via_mqtt_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -330,10 +325,16 @@ async def test_invalid_sensor_value_via_mqtt_message( "payload_off": "OFF", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_invalid_sensor_value_via_mqtt_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") @@ -356,13 +357,9 @@ async def test_invalid_sensor_value_via_mqtt_message( assert "No matching payload found for entity" in caplog.text -async def test_setting_sensor_value_via_mqtt_message_and_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -374,10 +371,14 @@ async def test_setting_sensor_value_via_mqtt_message_and_template( "{%-else-%}ON{%-endif%}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message_and_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -391,15 +392,9 @@ async def test_setting_sensor_value_via_mqtt_message_and_template( assert state.state == STATE_OFF -async def test_setting_sensor_value_via_mqtt_message_and_template2( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -411,9 +406,15 @@ async def test_setting_sensor_value_via_mqtt_message_and_template2( } } }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + ], +) +async def test_setting_sensor_value_via_mqtt_message_and_template2( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -432,15 +433,9 @@ async def test_setting_sensor_value_via_mqtt_message_and_template2( assert "template output: 'ILLEGAL'" in caplog.text -async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_encoding( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test processing a raw value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -452,10 +447,16 @@ async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_ "value_template": "{%if value|unpack('b')-%}ON{%else%}OFF{%-endif-%}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_encoding( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test processing a raw value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -469,13 +470,9 @@ async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_ assert state.state == STATE_OFF -async def test_setting_sensor_value_via_mqtt_message_empty_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -486,10 +483,14 @@ async def test_setting_sensor_value_via_mqtt_message_empty_template( "value_template": '{%if value == "ABC"%}ON{%endif%}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message_empty_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -503,13 +504,9 @@ async def test_setting_sensor_value_via_mqtt_message_empty_template( assert state.state == STATE_ON -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of a valid sensor class.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -518,22 +515,22 @@ async def test_valid_device_class( "state_topic": "test-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of a valid sensor class.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.attributes.get("device_class") == "motion" -async def test_invalid_device_class( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test the setting of an invalid sensor class.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -542,8 +539,17 @@ async def test_invalid_device_class( "state_topic": "test-topic", } } - }, - ) + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of an invalid sensor class.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert "Invalid config for [mqtt]: expected BinarySensorDeviceClass" in caplog.text @@ -585,13 +591,9 @@ async def test_custom_availability_payload( ) -async def test_force_update_disabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -601,10 +603,14 @@ async def test_force_update_disabled( "payload_off": "OFF", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_disabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() events = [] @@ -624,13 +630,9 @@ async def test_force_update_disabled( assert len(events) == 1 -async def test_force_update_enabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -641,10 +643,14 @@ async def test_force_update_enabled( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_enabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() events = [] @@ -664,13 +670,9 @@ async def test_force_update_enabled( assert len(events) == 2 -async def test_off_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test off_delay option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -682,10 +684,14 @@ async def test_off_delay( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_off_delay( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test off_delay option.""" + await mqtt_mock_entry_no_yaml_config() events = [] @@ -1068,40 +1074,54 @@ async def test_reloadable( @pytest.mark.parametrize( - ("payload1", "state1", "payload2", "state2"), - [("ON", "on", "OFF", "off"), ("OFF", "off", "ON", "on")], + ("hass_config", "payload1", "state1", "payload2", "state2"), + [ + ( + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ( + {"name": "test1", "expire_after": 30, "state_topic": "test-topic1"}, + {"name": "test2", "expire_after": 5, "state_topic": "test-topic2"}, + ), + ), + "ON", + "on", + "OFF", + "off", + ), + ( + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ( + {"name": "test1", "expire_after": 30, "state_topic": "test-topic1"}, + {"name": "test2", "expire_after": 5, "state_topic": "test-topic2"}, + ), + ), + "OFF", + "off", + "ON", + "on", + ), + ], ) async def test_cleanup_triggers_and_restoring_state( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, freezer: FrozenDateTimeFactory, + hass_config: ConfigType, payload1, state1, payload2, state2, ) -> None: """Test cleanup old triggers at reloading and restoring the state.""" - domain = binary_sensor.DOMAIN - config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config1["name"] = "test1" - config1["expire_after"] = 30 - config1["state_topic"] = "test-topic1" - config2 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config2["name"] = "test2" - config2["expire_after"] = 5 - config2["state_topic"] = "test-topic2" - freezer.move_to("2022-02-02 12:01:00+01:00") - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {binary_sensor.DOMAIN: [config1, config2]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic1", payload1) state = hass.states.get("binary_sensor.test1") @@ -1114,7 +1134,7 @@ async def test_cleanup_triggers_and_restoring_state( freezer.move_to("2022-02-02 12:01:10+01:00") await help_test_reload_with_config( - hass, caplog, tmp_path, {mqtt.DOMAIN: {domain: [config1, config2]}} + hass, caplog, tmp_path, {mqtt.DOMAIN: hass_config} ) state = hass.states.get("binary_sensor.test1") @@ -1132,9 +1152,19 @@ async def test_cleanup_triggers_and_restoring_state( assert state.state == state2 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ({"name": "test3", "expire_after": 10, "state_topic": "test-topic3"},), + ) + ], +) async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" @@ -1153,11 +1183,7 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( ) mock_restore_cache(hass, (fake_state,)) - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {domain: config3}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test3") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index f5a4648e34..6d238a63f4 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1,4 +1,5 @@ """Common test objects.""" +from collections.abc import Iterable from contextlib import suppress import copy from datetime import datetime @@ -119,6 +120,29 @@ async def help_setup_component( return mqtt_mock +def help_custom_config( + mqtt_entity_domain: str, + mqtt_base_config: ConfigType, + mqtt_entity_configs: Iterable[ConfigType,], +) -> ConfigType: + """Tweak a default config for parametrization. + + Returns a custom config to be used as parametrization for with hass_config, + based on the supplied mqtt_base_config and updated with mqtt_entity_configs. + For each item in mqtt_entity_configs an entity instance is added to the config. + """ + config: ConfigType = copy.deepcopy(mqtt_base_config) + entity_instances: list[ConfigType] = [] + for instance in mqtt_entity_configs: + base: ConfigType = copy.deepcopy( + mqtt_base_config[mqtt.DOMAIN][mqtt_entity_domain] + ) + base.update(instance) + entity_instances.append(base) + config[mqtt.DOMAIN][mqtt_entity_domain]: list[ConfigType] = entity_instances + return config + + async def help_test_availability_when_connection_lost( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, From 7efe058aa6d6d607f87982b9c152c02eda05728f Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 22 Mar 2023 10:46:17 +0100 Subject: [PATCH 0028/1165] Bump easyEnergy to v0.2.2 (#90080) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index fc0a4fd773..0954269628 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.2.1"] + "requirements": ["easyenergy==0.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index a19f989c58..0bfd27361c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.1 +easyenergy==0.2.2 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 639b7e2d4d..7a02110f54 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,7 +493,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.1 +easyenergy==0.2.2 # homeassistant.components.elgato elgato==4.0.1 From 87e6dd3949872a76173e2d3e3f6ecdea27c6d263 Mon Sep 17 00:00:00 2001 From: Matrix Date: Wed, 22 Mar 2023 19:01:04 +0800 Subject: [PATCH 0029/1165] YoLink flexfob support (#90027) --- homeassistant/components/yolink/__init__.py | 39 +++- homeassistant/components/yolink/const.py | 1 + .../components/yolink/device_trigger.py | 88 +++++++++ homeassistant/components/yolink/sensor.py | 5 +- homeassistant/components/yolink/strings.json | 12 ++ .../components/yolink/test_device_trigger.py | 169 ++++++++++++++++++ 6 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/yolink/device_trigger.py create mode 100644 tests/components/yolink/test_device_trigger.py diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7362a09609..c10cc8158e 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -7,6 +7,7 @@ from datetime import timedelta from typing import Any import async_timeout +from yolink.const import ATTR_DEVICE_SMART_REMOTER from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError from yolink.home_manager import YoLinkHome @@ -16,11 +17,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + device_registry as dr, +) from . import api -from .const import DOMAIN +from .const import DOMAIN, YOLINK_EVENT from .coordinator import YoLinkCoordinator +from .device_trigger import CONF_LONG_PRESS, CONF_SHORT_PRESS SCAN_INTERVAL = timedelta(minutes=5) @@ -53,9 +59,32 @@ class YoLinkHomeMessageListener(MessageListener): device_coordinators = entry_data.device_coordinators if not device_coordinators: return - device_coordiantor = device_coordinators.get(device.device_id) - if device_coordiantor is not None: - device_coordiantor.async_set_updated_data(msg_data) + device_coordinator = device_coordinators.get(device.device_id) + if device_coordinator is None: + return + device_coordinator.async_set_updated_data(msg_data) + # handling events + if ( + device_coordinator.device.device_type == ATTR_DEVICE_SMART_REMOTER + and msg_data.get("event") is not None + ): + device_registry = dr.async_get(self._hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_coordinator.device.device_id)} + ) + if device_entry is None: + return + key_press_type = None + if msg_data["event"]["type"] == "Press": + key_press_type = CONF_SHORT_PRESS + else: + key_press_type = CONF_LONG_PRESS + button_idx = msg_data["event"]["keyMask"] + event_data = { + "type": f"button_{button_idx}_{key_press_type}", + "device_id": device_entry.id, + } + self._hass.bus.async_fire(YOLINK_EVENT, event_data) @dataclass diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 61cbc8b302..935889a036 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -7,3 +7,4 @@ ATTR_DEVICE_TYPE = "type" ATTR_DEVICE_NAME = "name" ATTR_DEVICE_STATE = "state" ATTR_DEVICE_ID = "deviceId" +YOLINK_EVENT = f"{DOMAIN}_event" diff --git a/homeassistant/components/yolink/device_trigger.py b/homeassistant/components/yolink/device_trigger.py new file mode 100644 index 0000000000..aac860c6a2 --- /dev/null +++ b/homeassistant/components/yolink/device_trigger.py @@ -0,0 +1,88 @@ +"""Provides device triggers for YoLink.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from yolink.const import ATTR_DEVICE_SMART_REMOTER + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN, YOLINK_EVENT + +CONF_BUTTON_1 = "button_1" +CONF_BUTTON_2 = "button_2" +CONF_BUTTON_3 = "button_3" +CONF_BUTTON_4 = "button_4" +CONF_SHORT_PRESS = "short_press" +CONF_LONG_PRESS = "long_press" + +REMOTE_TRIGGER_TYPES = { + f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_2}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_3}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_3}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_4}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_4}_{CONF_LONG_PRESS}", +} + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): vol.In(REMOTE_TRIGGER_TYPES)} +) + + +# YoLink Remotes YS3604/YS3605/YS3606/YS3607 +DEVICE_TRIGGER_TYPES: dict[str, set[str]] = { + ATTR_DEVICE_SMART_REMOTER: REMOTE_TRIGGER_TYPES, +} + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for YoLink devices.""" + device_registry = dr.async_get(hass) + registry_device = device_registry.async_get(device_id) + if not registry_device or registry_device.model != ATTR_DEVICE_SMART_REMOTER: + return [] + + triggers = [] + for trigger in DEVICE_TRIGGER_TYPES[ATTR_DEVICE_SMART_REMOTER]: + triggers.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: trigger, + } + ) + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + event_config = { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: YOLINK_EVENT, + event_trigger.CONF_EVENT_DATA: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + CONF_TYPE: config[CONF_TYPE], + }, + } + event_config = event_trigger.TRIGGER_SCHEMA(event_config) + return await event_trigger.async_attach_trigger( + hass, event_config, action, trigger_info, platform_type="device" + ) diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 4850df4a26..5f89f54ccb 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -1,4 +1,4 @@ -"""YoLink Binary Sensor.""" +"""YoLink Sensor.""" from __future__ import annotations from collections.abc import Callable @@ -15,6 +15,7 @@ from yolink.const import ( ATTR_DEVICE_MULTI_OUTLET, ATTR_DEVICE_OUTLET, ATTR_DEVICE_SIREN, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_THERMOSTAT, @@ -68,6 +69,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_OUTLET, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_OUTLET, ATTR_DEVICE_SIREN, ATTR_DEVICE_SWITCH, @@ -84,6 +86,7 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_LOCK, diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 94fe5dc09a..de16e1a6e3 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -21,5 +21,17 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "device_automation": { + "trigger_type": { + "button_1_short_press": "Button_1 (short press)", + "button_1_long_press": "Button_1 (long press)", + "button_2_short_press": "Button_2 (short press)", + "button_2_long_press": "Button_2 (long press)", + "button_3_short_press": "Button_3 (short press)", + "button_3_long_press": "Button_3 (long press)", + "button_4_short_press": "Button_4 (short press)", + "button_4_long_press": "Button_4 (long press)" + } } } diff --git a/tests/components/yolink/test_device_trigger.py b/tests/components/yolink/test_device_trigger.py new file mode 100644 index 0000000000..f5679ca19c --- /dev/null +++ b/tests/components/yolink/test_device_trigger.py @@ -0,0 +1,169 @@ +"""The tests for YoLink device triggers.""" +import pytest +from yolink.const import ATTR_DEVICE_DIMMER, ATTR_DEVICE_SMART_REMOTER + +from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.yolink import DOMAIN, YOLINK_EVENT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, +) + + +@pytest.fixture +def calls(hass: HomeAssistant): + """Track calls to a mock service.""" + return async_mock_service(hass, "yolink", "automation") + + +async def test_get_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test we get the expected triggers from a yolink flexfob.""" + config_entry = MockConfigEntry(domain="yolink", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + model=ATTR_DEVICE_SMART_REMOTER, + ) + + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "button_1_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_1_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_2_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_2_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_3_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_3_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_4_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_4_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + ] + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert_lists_same(triggers, expected_triggers) + + +async def test_get_triggers_exception( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test get triggers when device type not flexfob.""" + config_entry = MockConfigEntry(domain="yolink", data={}) + config_entry.add_to_hass(hass) + device_entity = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + model=ATTR_DEVICE_DIMMER, + ) + + expected_triggers = [] + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entity.id + ) + assert_lists_same(triggers, expected_triggers) + + +async def test_if_fires_on_event( + hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry +) -> None: + """Test for event triggers firing.""" + mac_address = "12:34:56:AB:CD:EF" + connection = (dr.CONNECTION_NETWORK_MAC, mac_address) + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={connection}, + identifiers={(DOMAIN, mac_address)}, + model=ATTR_DEVICE_SMART_REMOTER, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "button_1_long_press", + }, + "action": { + "service": "yolink.automation", + "data": {"message": "service called"}, + }, + }, + ] + }, + ) + + device = device_registry.async_get_device(set(), {connection}) + assert device is not None + # Fake remote button long press. + hass.bus.async_fire( + event_type=YOLINK_EVENT, + event_data={ + "type": "button_1_long_press", + "device_id": device.id, + }, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["message"] == "service called" From 9b9ed21dc4b0de9bc8dccf5ec1ab047bcb084e8b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 08:24:28 -0400 Subject: [PATCH 0030/1165] Update hass-nabucasa to 0.62.0 (#90085) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/cloud/tts.py | 7 +++++-- homeassistant/components/tts/__init__.py | 3 ++- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7f8dfca144..7bd4a822fb 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.61.1"] + "requirements": ["hass-nabucasa==0.62.0"] } diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 00eacf7ca5..bbf4ef287d 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,7 +1,7 @@ """Support for the cloud for text to speech service.""" from hass_nabucasa import Cloud -from hass_nabucasa.voice import MAP_VOICE, VoiceError +from hass_nabucasa.voice import MAP_VOICE, AudioOutput, VoiceError import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider @@ -99,7 +99,10 @@ class CloudProvider(Provider): # Process TTS try: data = await self.cloud.voice.process_tts( - message, language, gender=options[CONF_GENDER] + message, + language, + gender=options[CONF_GENDER], + output=AudioOutput.MP3, ) except VoiceError: return (None, None) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 0d253d7d94..39aedfe8cb 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -502,7 +502,8 @@ class SpeechManager: ) # Save to memory - data = self.write_tags(filename, data, provider, message, language, options) + if extension == "mp3": + data = self.write_tags(filename, data, provider, message, language, options) self._async_store_to_memcache(cache_key, filename, data) if cache: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 29665559e4..edc88caef8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ ciso8601==2.3.0 cryptography==39.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 -hass-nabucasa==0.61.1 +hass-nabucasa==0.62.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230309.1 diff --git a/requirements_all.txt b/requirements_all.txt index 0bfd27361c..91b480e973 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -868,7 +868,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.61.1 +hass-nabucasa==0.62.0 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a02110f54..6b88020aff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -666,7 +666,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.61.1 +hass-nabucasa==0.62.0 # homeassistant.components.conversation hassil==1.0.6 From 19d56a7102ce1d2f58b64cba5c6f8b60b9c0c71a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 13:32:02 +0100 Subject: [PATCH 0031/1165] Change error handling in async_process_play_media_url (#90052) --- homeassistant/components/media_player/browse_media.py | 2 +- tests/components/media_player/test_browse_media.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index d1328a851d..2b046868f1 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -44,7 +44,7 @@ def async_process_play_media_url( return media_content_id else: if media_content_id[0] != "/": - raise ValueError("URL is relative, but does not start with a /") + return media_content_id if parsed.query: logging.getLogger(__name__).debug( diff --git a/tests/components/media_player/test_browse_media.py b/tests/components/media_player/test_browse_media.py index 014eeb7dc4..c7ce52eb12 100644 --- a/tests/components/media_player/test_browse_media.py +++ b/tests/components/media_player/test_browse_media.py @@ -86,8 +86,8 @@ async def test_process_play_media_url(hass: HomeAssistant, mock_sign_path) -> No == "http://example.local:8123/api/tts_proxy/bla" ) - with pytest.raises(ValueError): - async_process_play_media_url(hass, "hello") + # Not changing a URL which is not absolute and does not start with / + async_process_play_media_url(hass, "hello") == "hello" async def test_process_play_media_url_for_addon( From 0ca67233788038d0d00dfa4c3cd4de8e707f5b85 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 08:36:36 -0400 Subject: [PATCH 0032/1165] Allow passing binary to the WS connection (#89882) * Allow passing binary to the WS connection * Expand test coverage * Test non-existing handler * Allow signaling end of stream using empty payloads * Store handlers in a list * Handle binary handlers raising exceptions --- .../components/websocket_api/connection.py | 60 ++++++++++++- .../components/websocket_api/http.py | 9 ++ .../websocket_api/test_connection.py | 24 ++++++ tests/components/websocket_api/test_http.py | 85 ++++++++++++++++++- 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 08d0531452..f91cc3a827 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -25,6 +25,9 @@ current_connection = ContextVar["ActiveConnection | None"]( "current_connection", default=None ) +MessageHandler = Callable[[HomeAssistant, "ActiveConnection", dict[str, Any]], None] +BinaryHandler = Callable[[HomeAssistant, "ActiveConnection", bytes], None] + class ActiveConnection: """Handle an active websocket client connection.""" @@ -46,7 +49,10 @@ class ActiveConnection: self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 self.supported_features: dict[str, float] = {} - self.handlers = self.hass.data[const.DOMAIN] + self.handlers: dict[str, tuple[MessageHandler, vol.Schema]] = self.hass.data[ + const.DOMAIN + ] + self.binary_handlers: list[BinaryHandler | None] = [] current_connection.set(self) def get_description(self, request: web.Request | None) -> str: @@ -60,6 +66,38 @@ class ActiveConnection: """Return a context.""" return Context(user_id=self.user.id) + @callback + def async_register_binary_handler( + self, handler: BinaryHandler + ) -> tuple[int, Callable[[], None]]: + """Register a temporary binary handler for this connection. + + Returns a binary handler_id (1 byte) and a callback to unregister the handler. + """ + if len(self.binary_handlers) < 255: + index = len(self.binary_handlers) + self.binary_handlers.append(None) + else: + # Once the list is full, we search for a None entry to reuse. + index = None + for idx, existing in enumerate(self.binary_handlers): + if existing is None: + index = idx + break + + if index is None: + raise RuntimeError("Too many binary handlers registered") + + self.binary_handlers[index] = handler + + @callback + def unsub() -> None: + """Unregister the handler.""" + assert index is not None + self.binary_handlers[index] = None + + return index + 1, unsub + @callback def send_result(self, msg_id: int, result: Any | None = None) -> None: """Send a result message.""" @@ -75,6 +113,26 @@ class ActiveConnection: """Send a error message.""" self.send_message(messages.error_message(msg_id, code, message)) + @callback + def async_handle_binary(self, handler_id: int, payload: bytes) -> None: + """Handle a single incoming binary message.""" + index = handler_id - 1 + if ( + index < 0 + or index >= len(self.binary_handlers) + or (handler := self.binary_handlers[index]) is None + ): + self.logger.error( + "Received binary message for non-existing handler %s", handler_id + ) + return + + try: + handler(self.hass, self, payload) + except Exception: # pylint: disable=broad-except + self.logger.exception("Error handling binary message") + self.binary_handlers[index] = None + @callback def async_handle(self, msg: dict[str, Any]) -> None: """Handle a single incoming message.""" diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index de0b23e495..75eccc7aba 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -312,6 +312,15 @@ class WebSocketHandler: if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): break + if msg.type == WSMsgType.BINARY: + if len(msg.data) < 1: + disconnect_warn = "Received invalid binary message." + break + handler = msg.data[0] + payload = msg.data[1:] + connection.async_handle_binary(handler, payload) + continue + if msg.type != WSMsgType.TEXT: disconnect_warn = "Received non-Text message." break diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index 53baab98b4..da435d64d5 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -101,3 +101,27 @@ async def test_exception_handling( assert send_messages[0]["error"]["code"] == code assert send_messages[0]["error"]["message"] == err assert log in caplog.text + + +async def test_binary_handler_registration() -> None: + """Test binary handler registration.""" + connection = websocket_api.ActiveConnection( + None, Mock(data={websocket_api.DOMAIN: None}), None, None, Mock() + ) + + # One filler to align indexes with prefix numbers + unsubs = [None] + fake_handler = object() + for i in range(255): + prefix, unsub = connection.async_register_binary_handler(fake_handler) + assert prefix == i + 1 + unsubs.append(unsub) + + with pytest.raises(RuntimeError): + connection.async_register_binary_handler(None) + + unsubs[15]() + + # Verify we reuse an unsubscribed prefix + prefix, unsub = connection.async_register_binary_handler(None) + assert prefix == 15 diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index fce6eb428a..475fbeee76 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -1,13 +1,20 @@ """Test Websocket API http module.""" import asyncio from datetime import timedelta +from typing import Any from unittest.mock import patch from aiohttp import ServerDisconnectedError, WSMsgType, web import pytest -from homeassistant.components.websocket_api import const, http -from homeassistant.core import HomeAssistant +from homeassistant.components.websocket_api import ( + async_register_command, + const, + http, + websocket_command, +) +from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed @@ -155,3 +162,77 @@ async def test_prepare_fail( await hass_ws_client(hass) assert "Timeout preparing request" in caplog.text + + +async def test_binary_message( + hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture +) -> None: + """Test binary messages.""" + binary_payloads = { + 104: ([], asyncio.Future()), + 105: ([], asyncio.Future()), + } + + # Register a handler + @callback + @websocket_command( + { + "type": "get_binary_message_handler", + } + ) + def get_binary_message_handler( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] + ): + unsub = None + + @callback + def binary_message_handler( + hass: HomeAssistant, connection: ActiveConnection, payload: bytes + ): + nonlocal unsub + if msg["id"] == 103: + raise ValueError("Boom") + + if payload: + binary_payloads[msg["id"]][0].append(payload) + else: + binary_payloads[msg["id"]][1].set_result( + b"".join(binary_payloads[msg["id"]][0]) + ) + unsub() + + prefix, unsub = connection.async_register_binary_handler(binary_message_handler) + + connection.send_result(msg["id"], {"prefix": prefix}) + + async_register_command(hass, get_binary_message_handler) + + # Register multiple binary handlers + for i in range(101, 106): + await websocket_client.send_json( + {"id": i, "type": "get_binary_message_handler"} + ) + result = await websocket_client.receive_json() + assert result["id"] == i + assert result["type"] == const.TYPE_RESULT + assert result["success"] + assert result["result"]["prefix"] == i - 100 + + # Send message to binary + await websocket_client.send_bytes((0).to_bytes(1, "big") + b"test0") + await websocket_client.send_bytes((3).to_bytes(1, "big") + b"test3") + await websocket_client.send_bytes((3).to_bytes(1, "big") + b"test3") + await websocket_client.send_bytes((10).to_bytes(1, "big") + b"test10") + await websocket_client.send_bytes((4).to_bytes(1, "big") + b"test4") + await websocket_client.send_bytes((4).to_bytes(1, "big") + b"") + await websocket_client.send_bytes((5).to_bytes(1, "big") + b"test5") + await websocket_client.send_bytes((5).to_bytes(1, "big") + b"test5-2") + await websocket_client.send_bytes((5).to_bytes(1, "big") + b"") + + # Verify received + assert await binary_payloads[104][1] == b"test4" + assert await binary_payloads[105][1] == b"test5test5-2" + assert "Error handling binary message" in caplog.text + assert "Received binary message for non-existing handler 0" in caplog.text + assert "Received binary message for non-existing handler 3" in caplog.text + assert "Received binary message for non-existing handler 10" in caplog.text From 0ecd043cb2f0e84d8234fce6a82529988630caa5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 13:59:35 +0100 Subject: [PATCH 0033/1165] Add test helper mock_config_flow (#90103) --- tests/common.py | 11 +++++++++++ .../application_credentials/test_init.py | 8 ++++---- tests/components/hassio/test_discovery.py | 18 +++++++++++------- .../test_silabs_multiprotocol_addon.py | 15 ++++++++++----- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/tests/common.py b/tests/common.py index 569813d221..f7a2c04a5f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -34,6 +34,7 @@ from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) from homeassistant.config import async_process_component_config +from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_CLOSE, @@ -1242,6 +1243,16 @@ async def get_system_health_info(hass: HomeAssistant, domain: str) -> dict[str, return await hass.data["system_health"][domain].info_callback(hass) +@contextmanager +def mock_config_flow(domain: str, config_flow: type[ConfigFlow]) -> None: + """Mock a config flow handler.""" + assert domain not in config_entries.HANDLERS + config_entries.HANDLERS[domain] = config_flow + _LOGGER.info("Adding mock config flow: %s", domain) + yield + config_entries.HANDLERS.pop(domain) + + def mock_integration( hass: HomeAssistant, module: MockModule, built_in: bool = True ) -> loader.Integration: diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index 2f17340b07..cc56894cf0 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, mock_platform +from tests.common import MockConfigEntry, mock_config_flow, mock_platform from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -98,7 +98,7 @@ async def mock_application_credentials_integration( yield -class FakeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN): +class FakeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): """Config flow used during tests.""" DOMAIN = TEST_DOMAIN @@ -115,8 +115,8 @@ def config_flow_handler( ) -> Generator[FakeConfigFlow, None, None]: """Fixture for a test config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with patch.dict(config_entries.HANDLERS, {TEST_DOMAIN: FakeConfigFlow}): - yield FakeConfigFlow + with mock_config_flow(TEST_DOMAIN, FakeConfigFlow): + yield class OAuthFixture: diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 2cb4aa206d..51659927df 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -12,7 +12,12 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_S from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockModule, mock_entity_platform, mock_integration +from tests.common import ( + MockModule, + mock_config_flow, + mock_entity_platform, + mock_integration, +) from tests.test_util.aiohttp import AiohttpClientMocker @@ -22,15 +27,14 @@ async def mock_mqtt_fixture(hass): mock_integration(hass, MockModule(MQTT_DOMAIN)) mock_entity_platform(hass, f"config_flow.{MQTT_DOMAIN}", None) - with patch.dict(config_entries.HANDLERS): + class MqttFlow(config_entries.ConfigFlow): + """Test flow.""" - class MqttFlow(config_entries.ConfigFlow, domain=MQTT_DOMAIN): - """Test flow.""" + VERSION = 1 - VERSION = 1 - - async_step_hassio = AsyncMock(return_value={"type": "abort"}) + async_step_hassio = AsyncMock(return_value={"type": "abort"}) + with mock_config_flow(MQTT_DOMAIN, MqttFlow): yield MqttFlow diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index abe66d35a9..57e4a23ab5 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -7,7 +7,6 @@ from unittest.mock import Mock, patch import pytest -from homeassistant import config_entries from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN @@ -15,12 +14,18 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult, FlowResultType -from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, +) TEST_DOMAIN = "test" -class TestConfigFlow(ConfigFlow, domain=TEST_DOMAIN): +class TestConfigFlow(ConfigFlow): """Handle a config flow for the silabs multiprotocol add-on.""" VERSION = 1 @@ -87,8 +92,8 @@ def config_flow_handler( ) -> Generator[TestConfigFlow, None, None]: """Fixture for a test config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with patch.dict(config_entries.HANDLERS, {TEST_DOMAIN: TestConfigFlow}): - yield TestConfigFlow + with mock_config_flow(TEST_DOMAIN, TestConfigFlow): + yield async def test_option_flow_install_multi_pan_addon( From 130c8ea5f54f73ed84e34da20683ebbd2b9d4ce0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 14:03:39 +0100 Subject: [PATCH 0034/1165] Update OTRB config entry if REST API port has changed (#90101) * Update OTRB config entry if REST API port has changed * Improve test coverage --- homeassistant/components/otbr/__init__.py | 7 ++ homeassistant/components/otbr/config_flow.py | 25 +++++-- tests/components/otbr/test_config_flow.py | 68 +++++++++++++++++++- tests/components/otbr/test_init.py | 27 +++++++- 4 files changed, 118 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 602c76f77e..a25ff8b46b 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -155,6 +155,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _warn_on_default_network_settings(hass, entry, dataset_tlvs) await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + hass.data[DOMAIN] = otbrdata return True @@ -166,6 +168,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_get_active_dataset_tlvs(hass: HomeAssistant) -> bytes | None: """Get current active operational dataset in TLVS format, or None. diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 0e9c8e9606..4247d5dbd6 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -8,10 +8,11 @@ import aiohttp import python_otbr_api from python_otbr_api import tlv_parser import voluptuous as vol +import yarl from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.thread import async_get_preferred_dataset -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow from homeassistant.const import CONF_URL from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -86,11 +87,25 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Handle hassio discovery.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - config = discovery_info.config url = f"http://{config['host']}:{config['port']}" + config_entry_data = {"url": url} + + if current_entries := self._async_current_entries(): + for current_entry in current_entries: + if current_entry.source != SOURCE_HASSIO: + continue + current_url = yarl.URL(current_entry.data["url"]) + if ( + current_url.host != config["host"] + or current_url.port == config["port"] + ): + continue + # Update URL with the new port + self.hass.config_entries.async_update_entry( + current_entry, data=config_entry_data + ) + return self.async_abort(reason="single_instance_allowed") try: await self._connect_and_create_dataset(url) @@ -101,5 +116,5 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(DOMAIN) return self.async_create_entry( title="Open Thread Border Router", - data={"url": url}, + data=config_entry_data, ) diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 2ec79dcaee..ae49c63002 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Open Thread Border Router config flow.""" import asyncio from http import HTTPStatus +from typing import Any from unittest.mock import patch import aiohttp @@ -373,8 +374,69 @@ async def test_hassio_discovery_flow_404( assert result["reason"] == "unknown" -@pytest.mark.parametrize("source", ("hassio", "user")) -async def test_config_flow_single_entry(hass: HomeAssistant, source: str) -> None: +async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None: + """Test the port can be updated.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={ + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + }, + domain=otbr.DOMAIN, + options={}, + source="hassio", + title="Open Thread Border Router", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + expected_data = { + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", + } + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + + +async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) -> None: + """Test the port is not updated if we get data for another addon hosting OTBR.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={"url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}"}, + domain=otbr.DOMAIN, + options={}, + source="hassio", + title="Open Thread Border Router", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + # Make sure the data was not updated + expected_data = { + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}", + } + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + + +@pytest.mark.parametrize(("source", "data"), [("hassio", HASSIO_DATA), ("user", None)]) +async def test_config_flow_single_entry( + hass: HomeAssistant, source: str, data: Any +) -> None: """Test only a single entry is allowed.""" mock_integration(hass, MockModule("hassio")) @@ -392,7 +454,7 @@ async def test_config_flow_single_entry(hass: HomeAssistant, source: str) -> Non return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - otbr.DOMAIN, context={"source": source} + otbr.DOMAIN, context={"source": source}, data=data ) assert result["type"] == FlowResultType.ABORT diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 2b329ae8d9..3ed3ec8c30 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,7 +1,7 @@ """Test the Open Thread Border Router integration.""" import asyncio from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import aiohttp import pytest @@ -100,6 +100,31 @@ async def test_config_entry_not_ready(hass: HomeAssistant, error) -> None: assert not await hass.config_entries.async_setup(config_entry.entry_id) +async def test_config_entry_update(hass: HomeAssistant) -> None: + """Test update config entry settings.""" + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA, + domain=otbr.DOMAIN, + options={}, + title="My OTBR", + ) + config_entry.add_to_hass(hass) + mock_api = MagicMock() + mock_api.get_active_dataset_tlvs = AsyncMock(return_value=None) + with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: + assert await hass.config_entries.async_setup(config_entry.entry_id) + + mock_otrb_api.assert_called_once_with(CONFIG_ENTRY_DATA["url"], ANY, ANY) + + new_config_entry_data = {"url": "http://core-silabs-multiprotocol:8082"} + assert CONFIG_ENTRY_DATA["url"] != new_config_entry_data["url"] + with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: + hass.config_entries.async_update_entry(config_entry, data=new_config_entry_data) + await hass.async_block_till_done() + + mock_otrb_api.assert_called_once_with(new_config_entry_data["url"], ANY, ANY) + + async def test_remove_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry ) -> None: From c581116c824b008c36ef0e05c6d098450f63bb04 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 22 Mar 2023 11:15:46 -0400 Subject: [PATCH 0035/1165] ZHA network settings API (#88564) * Rename `zha.api` to `zha.websocket_api` * Implement a ZHA network settings API * Use the enum name as the radio type * Don't filter out ignored config entries * [WIP] Start unit tests * Add unit tests * Rename ZHA websocket API module in `.coveragerc` * Rename `api` to `websocket_api` * Increase test coverage to 100% --- .coveragerc | 2 +- homeassistant/components/zha/__init__.py | 8 +- homeassistant/components/zha/api.py | 1615 +---------------- homeassistant/components/zha/core/device.py | 2 +- homeassistant/components/zha/core/gateway.py | 22 +- homeassistant/components/zha/device_action.py | 2 +- homeassistant/components/zha/websocket_api.py | 1541 ++++++++++++++++ tests/components/zha/test_api.py | 869 +-------- tests/components/zha/test_init.py | 4 +- tests/components/zha/test_websocket_api.py | 842 +++++++++ 10 files changed, 2558 insertions(+), 2349 deletions(-) create mode 100644 homeassistant/components/zha/websocket_api.py create mode 100644 tests/components/zha/test_websocket_api.py diff --git a/.coveragerc b/.coveragerc index 20ee077ffa..e59c60ddcc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1508,7 +1508,7 @@ omit = homeassistant/components/zeversolar/coordinator.py homeassistant/components/zeversolar/entity.py homeassistant/components/zeversolar/sensor.py - homeassistant/components/zha/api.py + homeassistant/components/zha/websocket_api.py homeassistant/components/zha/core/channels/* homeassistant/components/zha/core/device.py homeassistant/components/zha/core/gateway.py diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index dd07d4da42..5607cabffe 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType -from . import api +from . import websocket_api from .core import ZHAGateway from .core.const import ( BAUD_RATES, @@ -131,7 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b model=zha_gateway.radio_description, ) - api.async_load_api(hass) + websocket_api.async_load_api(hass) async def async_zha_shutdown(event): """Handle shutdown tasks.""" @@ -150,11 +150,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA].pop(DATA_ZHA_GATEWAY) await zha_gateway.shutdown() GROUP_PROBE.cleanup() - api.async_unload_api(hass) + websocket_api.async_unload_api(hass) # our components don't have unload methods so no need to look at return values await asyncio.gather( diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index d0e04e0c16..d34dd2338e 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -1,1549 +1,120 @@ -"""Web socket API for Zigbee Home Automation devices.""" +"""API for Zigbee Home Automation.""" + from __future__ import annotations -import asyncio -import logging -from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast +from typing import TYPE_CHECKING -import voluptuous as vol -import zigpy.backups from zigpy.backups import NetworkBackup -from zigpy.config.validators import cv_boolean -from zigpy.types.named import EUI64 -from zigpy.zcl.clusters.security import IasAce -import zigpy.zdo.types as zdo_types - -from homeassistant.components import websocket_api -from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME -from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.service import async_register_admin_service +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from .core.const import ( - ATTR_ARGS, - ATTR_ATTRIBUTE, - ATTR_CLUSTER_ID, - ATTR_CLUSTER_TYPE, - ATTR_COMMAND_TYPE, - ATTR_ENDPOINT_ID, - ATTR_IEEE, - ATTR_LEVEL, - ATTR_MANUFACTURER, - ATTR_MEMBERS, - ATTR_PARAMS, - ATTR_TYPE, - ATTR_VALUE, - ATTR_WARNING_DEVICE_DURATION, - ATTR_WARNING_DEVICE_MODE, - ATTR_WARNING_DEVICE_STROBE, - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, - ATTR_WARNING_DEVICE_STROBE_INTENSITY, - BINDINGS, - CHANNEL_IAS_WD, - CLUSTER_COMMAND_SERVER, - CLUSTER_COMMANDS_CLIENT, - CLUSTER_COMMANDS_SERVER, - CLUSTER_TYPE_IN, - CLUSTER_TYPE_OUT, CONF_RADIO_TYPE, - CUSTOM_CONFIGURATION, DATA_ZHA, + DATA_ZHA_CONFIG, DATA_ZHA_GATEWAY, DOMAIN, - EZSP_OVERWRITE_EUI64, - GROUP_ID, - GROUP_IDS, - GROUP_NAME, - MFG_CLUSTER_ID_START, - WARNING_DEVICE_MODE_EMERGENCY, - WARNING_DEVICE_SOUND_HIGH, - WARNING_DEVICE_SQUAWK_MODE_ARMED, - WARNING_DEVICE_STROBE_HIGH, - WARNING_DEVICE_STROBE_YES, - ZHA_ALARM_OPTIONS, - ZHA_CHANNEL_MSG, - ZHA_CONFIG_SCHEMAS, -) -from .core.gateway import EntityReference -from .core.group import GroupMember -from .core.helpers import ( - async_cluster_exists, - async_is_bindable_target, - cluster_command_schema_to_vol_schema, - convert_install_code, - get_matched_clusters, - qr_to_install_code, + RadioType, ) +from .core.gateway import ZHAGateway if TYPE_CHECKING: - from homeassistant.components.websocket_api.connection import ActiveConnection + from zigpy.application import ControllerApplication - from .core.device import ZHADevice - from .core.gateway import ZHAGateway - -_LOGGER = logging.getLogger(__name__) - -TYPE = "type" -CLIENT = "client" -ID = "id" -RESPONSE = "response" -DEVICE_INFO = "device_info" - -ATTR_DURATION = "duration" -ATTR_GROUP = "group" -ATTR_IEEE_ADDRESS = "ieee_address" -ATTR_INSTALL_CODE = "install_code" -ATTR_SOURCE_IEEE = "source_ieee" -ATTR_TARGET_IEEE = "target_ieee" -ATTR_QR_CODE = "qr_code" - -SERVICE_PERMIT = "permit" -SERVICE_REMOVE = "remove" -SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" -SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" -SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" -SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" -SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" -SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" -SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" -SERVICE_ZIGBEE_BIND = "service_zigbee_bind" -IEEE_SERVICE = "ieee_based_service" - -IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) - -# typing typevar -_T = TypeVar("_T") + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant -def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: - """Wrap value in list if it is provided and not one.""" - if value is None: +def _get_gateway(hass: HomeAssistant) -> ZHAGateway: + """Get a reference to the ZHA gateway device.""" + return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + +def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Find the singleton ZHA config entry, if one exists.""" + + # If ZHA is already running, use its config entry + try: + zha_gateway = _get_gateway(hass) + except KeyError: + pass + else: + return zha_gateway.config_entry + + # Otherwise, find one + entries = hass.config_entries.async_entries(DOMAIN) + + if len(entries) != 1: + raise ValueError(f"Invalid number of ZHA config entries: {entries!r}") + + return entries[0] + + +def _wrap_network_settings(app: ControllerApplication) -> NetworkBackup: + """Wrap the ZHA network settings into a `NetworkBackup`.""" + return NetworkBackup( + node_info=app.state.node_info, + network_info=app.state.network_info, + ) + + +def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: + """Get the network settings for the currently active ZHA network.""" + zha_gateway: ZHAGateway = _get_gateway(hass) + + return _wrap_network_settings(zha_gateway.application_controller) + + +async def async_get_last_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get the network settings for the last-active ZHA network.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + + config = hass.data.get(DATA_ZHA, {}).get(DATA_ZHA_CONFIG, {}) + zha_gateway = ZHAGateway(hass, config, config_entry) + + app_controller_cls, app_config = zha_gateway.get_application_controller_data() + app = app_controller_cls(app_config) + + try: + await app._load_db() # pylint: disable=protected-access + settings = _wrap_network_settings(app) + finally: + await app.shutdown() + + if settings.network_info.channel == 0: return None - return cast("list[_T]", value) if isinstance(value, list) else [value] + + return settings -SERVICE_PERMIT_PARAMS = { - vol.Optional(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional(ATTR_DURATION, default=60): vol.All( - vol.Coerce(int), vol.Range(0, 254) - ), - vol.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, - vol.Inclusive(ATTR_INSTALL_CODE, "install_code"): vol.All( - cv.string, convert_install_code - ), - vol.Exclusive(ATTR_QR_CODE, "install_code"): vol.All(cv.string, qr_to_install_code), -} - -SERVICE_SCHEMAS = { - SERVICE_PERMIT: vol.Schema( - vol.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - SERVICE_PERMIT_PARAMS, - ) - ), - IEEE_SERVICE: vol.Schema( - vol.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - {vol.Required(ATTR_IEEE): IEEE_SCHEMA}, - ) - ), - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_ATTRIBUTE): vol.Any(cv.positive_int, str), - vol.Required(ATTR_VALUE): vol.Any(int, cv.boolean, cv.string), - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), - SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES - ): cv.positive_int, - vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH - ): cv.positive_int, - } - ), - SERVICE_WARNING_DEVICE_WARN: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES - ): cv.positive_int, - vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH - ): cv.positive_int, - vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH - ): cv.positive_int, - } - ), - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( - vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_COMMAND): cv.positive_int, - vol.Required(ATTR_COMMAND_TYPE): cv.string, - vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, - vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), - cv.deprecated(ATTR_ARGS), - cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), - ), - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( - { - vol.Required(ATTR_GROUP): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_COMMAND): cv.positive_int, - vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), -} - - -class ClusterBinding(NamedTuple): - """Describes a cluster binding.""" - - name: str - type: str - id: int - endpoint_id: int - - -def _cv_group_member(value: dict[str, Any]) -> GroupMember: - """Transform a group member.""" - return GroupMember( - ieee=value[ATTR_IEEE], - endpoint_id=value[ATTR_ENDPOINT_ID], - ) - - -def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding: - """Transform a cluster binding.""" - return ClusterBinding( - name=value[ATTR_NAME], - type=value[ATTR_TYPE], - id=value[ATTR_ID], - endpoint_id=value[ATTR_ENDPOINT_ID], - ) - - -def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup: - """Transform a zigpy network backup.""" +async def async_get_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get ZHA network settings, preferring the active settings if ZHA is running.""" try: - return zigpy.backups.NetworkBackup.from_dict(value) - except ValueError as err: - raise vol.Invalid(str(err)) from err + return async_get_active_network_settings(hass) + except KeyError: + return await async_get_last_network_settings(hass, config_entry) -GROUP_MEMBER_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), - } - ), - _cv_group_member, -) +def async_get_radio_type( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> RadioType: + """Get ZHA radio type.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + return RadioType[config_entry.data[CONF_RADIO_TYPE]] -CLUSTER_BINDING_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_NAME): cv.string, - vol.Required(ATTR_TYPE): cv.string, - vol.Required(ATTR_ID): vol.Coerce(int), - vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), - } - ), - _cv_cluster_binding, -) +def async_get_radio_path( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> str: + """Get ZHA radio path.""" + if config_entry is None: + config_entry = _get_config_entry(hass) -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "zha/devices/permit", - **SERVICE_PERMIT_PARAMS, - } -) -@websocket_api.async_response -async def websocket_permit_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Permit ZHA zigbee devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - duration: int = msg[ATTR_DURATION] - ieee: EUI64 | None = msg.get(ATTR_IEEE) - - async def forward_messages(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.event_message(msg["id"], data)) - - remove_dispatcher_function = async_dispatcher_connect( - hass, "zha_gateway_message", forward_messages - ) - - @callback - def async_cleanup() -> None: - """Remove signal listener and turn off debug mode.""" - zha_gateway.async_disable_debug_mode() - remove_dispatcher_function() - - connection.subscriptions[msg["id"]] = async_cleanup - zha_gateway.async_enable_debug_mode() - src_ieee: EUI64 - code: bytes - if ATTR_SOURCE_IEEE in msg: - src_ieee = msg[ATTR_SOURCE_IEEE] - code = msg[ATTR_INSTALL_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - elif ATTR_QR_CODE in msg: - src_ieee, code = msg[ATTR_QR_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - else: - await zha_gateway.application_controller.permit(time_s=duration, node=ieee) - connection.send_result(msg[ID]) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices"}) -@websocket_api.async_response -async def websocket_get_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - devices = [device.zha_device_info for device in zha_gateway.devices.values()] - connection.send_result(msg[ID], devices) - - -@callback -def _get_entity_name( - zha_gateway: ZHAGateway, entity_ref: EntityReference -) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) - return entry.name if entry else None - - -@callback -def _get_entity_original_name( - zha_gateway: ZHAGateway, entity_ref: EntityReference -) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) - return entry.original_name if entry else None - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) -@websocket_api.async_response -async def websocket_get_groupable_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices that can be grouped.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - - devices = [device for device in zha_gateway.devices.values() if device.is_groupable] - groupable_devices = [] - - for device in devices: - entity_refs = zha_gateway.device_registry[device.ieee] - for ep_id in device.async_get_groupable_endpoints(): - groupable_devices.append( - { - "endpoint_id": ep_id, - "entities": [ - { - "name": _get_entity_name(zha_gateway, entity_ref), - "original_name": _get_entity_original_name( - zha_gateway, entity_ref - ), - } - for entity_ref in entity_refs - if list(entity_ref.cluster_channels.values())[ - 0 - ].cluster.endpoint.endpoint_id - == ep_id - ], - "device": device.zha_device_info, - } - ) - - connection.send_result(msg[ID], groupable_devices) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"}) -@websocket_api.async_response -async def websocket_get_groups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - groups = [group.group_info for group in zha_gateway.groups.values()] - connection.send_result(msg[ID], groups) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/device", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_get_device( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - - if not (zha_device := zha_gateway.devices.get(ieee)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Device not found" - ) - ) - return - - device_info = zha_device.zha_device_info - connection.send_result(msg[ID], device_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group", - vol.Required(GROUP_ID): cv.positive_int, - } -) -@websocket_api.async_response -async def websocket_get_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - group_info = zha_group.group_info - connection.send_result(msg[ID], group_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/add", - vol.Required(GROUP_NAME): cv.string, - vol.Optional(GROUP_ID): cv.positive_int, - vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_add_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Add a new ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_name: str = msg[GROUP_NAME] - group_id: int | None = msg.get(GROUP_ID) - members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) - group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) - assert group - connection.send_result(msg[ID], group.group_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/remove", - vol.Required(GROUP_IDS): vol.All(cv.ensure_list, [cv.positive_int]), - } -) -@websocket_api.async_response -async def websocket_remove_groups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove the specified ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_ids: list[int] = msg[GROUP_IDS] - - if len(group_ids) > 1: - tasks = [] - for group_id in group_ids: - tasks.append(zha_gateway.async_remove_zigpy_group(group_id)) - await asyncio.gather(*tasks) - else: - await zha_gateway.async_remove_zigpy_group(group_ids[0]) - ret_groups = [group.group_info for group in zha_gateway.groups.values()] - connection.send_result(msg[ID], ret_groups) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/members/add", - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_add_group_members( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Add members to a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - members: list[GroupMember] = msg[ATTR_MEMBERS] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - await zha_group.async_add_members(members) - ret_group = zha_group.group_info - connection.send_result(msg[ID], ret_group) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/members/remove", - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_remove_group_members( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove members from a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - members: list[GroupMember] = msg[ATTR_MEMBERS] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - await zha_group.async_remove_members(members) - ret_group = zha_group.group_info - connection.send_result(msg[ID], ret_group) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/reconfigure", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_reconfigure_node( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Reconfigure a ZHA nodes entities by its ieee address.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - device: ZHADevice | None = zha_gateway.get_device(ieee) - - async def forward_messages(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.event_message(msg["id"], data)) - - remove_dispatcher_function = async_dispatcher_connect( - hass, ZHA_CHANNEL_MSG, forward_messages - ) - - @callback - def async_cleanup() -> None: - """Remove signal listener.""" - remove_dispatcher_function() - - connection.subscriptions[msg["id"]] = async_cleanup - - _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) - assert device - hass.async_create_task(device.async_configure()) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/topology/update", - } -) -@websocket_api.async_response -async def websocket_update_topology( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Update the ZHA network topology.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - hass.async_create_task(zha_gateway.application_controller.topology.scan()) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_device_clusters( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of device clusters.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - zha_device = zha_gateway.get_device(ieee) - response_clusters = [] - if zha_device is not None: - clusters_by_endpoint = zha_device.async_get_clusters() - for ep_id, clusters in clusters_by_endpoint.items(): - for c_id, cluster in clusters[CLUSTER_TYPE_IN].items(): - response_clusters.append( - { - TYPE: CLUSTER_TYPE_IN, - ID: c_id, - ATTR_NAME: cluster.__class__.__name__, - "endpoint_id": ep_id, - } - ) - for c_id, cluster in clusters[CLUSTER_TYPE_OUT].items(): - response_clusters.append( - { - TYPE: CLUSTER_TYPE_OUT, - ID: c_id, - ATTR_NAME: cluster.__class__.__name__, - "endpoint_id": ep_id, - } - ) - - connection.send_result(msg[ID], response_clusters) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/attributes", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - } -) -@websocket_api.async_response -async def websocket_device_cluster_attributes( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of cluster attributes.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - cluster_attributes: list[dict[str, Any]] = [] - zha_device = zha_gateway.get_device(ieee) - attributes = None - if zha_device is not None: - attributes = zha_device.async_get_cluster_attributes( - endpoint_id, cluster_id, cluster_type - ) - if attributes is not None: - for attr_id, attr in attributes.items(): - cluster_attributes.append({ID: attr_id, ATTR_NAME: attr.name}) - _LOGGER.debug( - "Requested attributes for: %s: %s, %s: '%s', %s: %s, %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - RESPONSE, - cluster_attributes, - ) - - connection.send_result(msg[ID], cluster_attributes) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/commands", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - } -) -@websocket_api.async_response -async def websocket_device_cluster_commands( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of cluster commands.""" - import voluptuous_serialize # pylint: disable=import-outside-toplevel - - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - zha_device = zha_gateway.get_device(ieee) - cluster_commands: list[dict[str, Any]] = [] - commands = None - if zha_device is not None: - commands = zha_device.async_get_cluster_commands( - endpoint_id, cluster_id, cluster_type - ) - - if commands is not None: - for cmd_id, cmd in commands[CLUSTER_COMMANDS_CLIENT].items(): - cluster_commands.append( - { - TYPE: CLIENT, - ID: cmd_id, - ATTR_NAME: cmd.name, - "schema": voluptuous_serialize.convert( - cluster_command_schema_to_vol_schema(cmd.schema), - custom_serializer=cv.custom_serializer, - ), - } - ) - for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): - cluster_commands.append( - { - TYPE: CLUSTER_COMMAND_SERVER, - ID: cmd_id, - ATTR_NAME: cmd.name, - "schema": voluptuous_serialize.convert( - cluster_command_schema_to_vol_schema(cmd.schema), - custom_serializer=cv.custom_serializer, - ), - } - ) - _LOGGER.debug( - "Requested commands for: %s: %s, %s: '%s', %s: %s, %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - RESPONSE, - cluster_commands, - ) - - connection.send_result(msg[ID], cluster_commands) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/attributes/value", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - vol.Required(ATTR_ATTRIBUTE): int, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } -) -@websocket_api.async_response -async def websocket_read_zigbee_cluster_attributes( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Read zigbee attribute for cluster on ZHA entity.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - attribute: int = msg[ATTR_ATTRIBUTE] - manufacturer: int | None = msg.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - success = {} - failure = {} - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - cluster = zha_device.async_get_cluster( - endpoint_id, cluster_id, cluster_type=cluster_type - ) - success, failure = await cluster.read_attributes( - [attribute], allow_cache=False, only_cache=False, manufacturer=manufacturer - ) - _LOGGER.debug( - ( - "Read attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" - " %s: [%s]," - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_ATTRIBUTE, - attribute, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - str(success.get(attribute)), - "failure", - failure, - ) - connection.send_result(msg[ID], str(success.get(attribute))) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/bindable", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_get_bindable_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_IEEE] - source_device = zha_gateway.get_device(source_ieee) - - devices = [ - device.zha_device_info - for device in zha_gateway.devices.values() - if async_is_bindable_target(source_device, device) - ] - - _LOGGER.debug( - "Get bindable devices: %s: [%s], %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - "bindable devices", - devices, - ) - - connection.send_message(websocket_api.result_message(msg[ID], devices)) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/bind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_bind_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Bind_req - ) - _LOGGER.info( - "Devices bound: %s: [%s] %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - ATTR_TARGET_IEEE, - target_ieee, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/unbind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_unbind_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove a direct binding between devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Unbind_req - ) - _LOGGER.info( - "Devices un-bound: %s: [%s] %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - ATTR_TARGET_IEEE, - target_ieee, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/groups/bind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_bind_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind a device to a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - group_id: int = msg[GROUP_ID] - bindings: list[ClusterBinding] = msg[BINDINGS] - source_device = zha_gateway.get_device(source_ieee) - assert source_device - await source_device.async_bind_to_group(group_id, bindings) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/groups/unbind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_unbind_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Unbind a device from a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - group_id: int = msg[GROUP_ID] - bindings: list[ClusterBinding] = msg[BINDINGS] - source_device = zha_gateway.get_device(source_ieee) - assert source_device - await source_device.async_unbind_from_group(group_id, bindings) - - -async def async_binding_operation( - zha_gateway: ZHAGateway, - source_ieee: EUI64, - target_ieee: EUI64, - operation: zdo_types.ZDOCmd, -) -> None: - """Create or remove a direct zigbee binding between 2 devices.""" - - source_device = zha_gateway.get_device(source_ieee) - target_device = zha_gateway.get_device(target_ieee) - - assert source_device - assert target_device - clusters_to_bind = await get_matched_clusters(source_device, target_device) - - zdo = source_device.device.zdo - bind_tasks = [] - for binding_pair in clusters_to_bind: - op_msg = "cluster: %s %s --> [%s]" - op_params = ( - binding_pair.source_cluster.cluster_id, - operation.name, - target_ieee, - ) - zdo.debug(f"processing {op_msg}", *op_params) - - bind_tasks.append( - ( - zdo.request( - operation, - source_device.ieee, - binding_pair.source_cluster.endpoint.endpoint_id, - binding_pair.source_cluster.cluster_id, - binding_pair.destination_address, - ), - op_msg, - op_params, - ) - ) - res = await asyncio.gather(*(t[0] for t in bind_tasks), return_exceptions=True) - for outcome, log_msg in zip(res, bind_tasks): - if isinstance(outcome, Exception): - fmt = f"{log_msg[1]} failed: %s" - else: - fmt = f"{log_msg[1]} completed: %s" - zdo.debug(fmt, *(log_msg[2] + (outcome,))) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/configuration"}) -@websocket_api.async_response -async def websocket_get_configuration( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - import voluptuous_serialize # pylint: disable=import-outside-toplevel - - def custom_serializer(schema: Any) -> Any: - """Serialize additional types for voluptuous_serialize.""" - if schema is cv_boolean: - return {"type": "bool"} - if schema is vol.Schema: - return voluptuous_serialize.convert( - schema, custom_serializer=custom_serializer - ) - - return cv.custom_serializer(schema) - - data: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} - for section, schema in ZHA_CONFIG_SCHEMAS.items(): - if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( - hass, IasAce.cluster_id - ): - continue - data["schemas"][section] = voluptuous_serialize.convert( - schema, custom_serializer=custom_serializer - ) - data["data"][section] = zha_gateway.config_entry.options.get( - CUSTOM_CONFIGURATION, {} - ).get(section, {}) - - # send default values for unconfigured options - for entry in data["schemas"][section]: - if data["data"][section].get(entry["name"]) is None: - data["data"][section][entry["name"]] = entry["default"] - - connection.send_result(msg[ID], data) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/configuration/update", - vol.Required("data"): ZHA_CONFIG_SCHEMAS, - } -) -@websocket_api.async_response -async def websocket_update_zha_configuration( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Update the ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - options = zha_gateway.config_entry.options - data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} - - for section, schema in ZHA_CONFIG_SCHEMAS.items(): - for entry in schema.schema: - # remove options that match defaults - if ( - data_to_save[CUSTOM_CONFIGURATION].get(section, {}).get(entry) - == entry.default() - ): - data_to_save[CUSTOM_CONFIGURATION][section].pop(entry) - # remove entire section block if empty - if ( - not data_to_save[CUSTOM_CONFIGURATION].get(section) - and section in data_to_save[CUSTOM_CONFIGURATION] - ): - data_to_save[CUSTOM_CONFIGURATION].pop(section) - - # remove entire custom_configuration block if empty - if ( - not data_to_save.get(CUSTOM_CONFIGURATION) - and CUSTOM_CONFIGURATION in data_to_save - ): - data_to_save.pop(CUSTOM_CONFIGURATION) - - _LOGGER.info( - "Updating ZHA custom configuration options from %s to %s", - options, - data_to_save, - ) - - hass.config_entries.async_update_entry( - zha_gateway.config_entry, options=data_to_save - ) - status = await hass.config_entries.async_reload(zha_gateway.config_entry.entry_id) - connection.send_result(msg[ID], status) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/settings"}) -@websocket_api.async_response -async def websocket_get_network_settings( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # Serialize the current network settings - backup = NetworkBackup( - node_info=application_controller.state.node_info, - network_info=application_controller.state.network_info, - ) - - connection.send_result( - msg[ID], - { - "radio_type": zha_gateway.config_entry.data[CONF_RADIO_TYPE], - "settings": backup.as_dict(), - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"}) -@websocket_api.async_response -async def websocket_list_network_backups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # Serialize known backups - connection.send_result( - msg[ID], [backup.as_dict() for backup in application_controller.backups] - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"}) -@websocket_api.async_response -async def websocket_create_network_backup( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Create a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # This can take 5-30s - backup = await application_controller.backups.create_backup(load_devices=True) - connection.send_result( - msg[ID], - { - "backup": backup.as_dict(), - "is_complete": backup.is_complete(), - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/network/backups/restore", - vol.Required("backup"): _cv_zigpy_network_backup, - vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean, - } -) -@websocket_api.async_response -async def websocket_restore_network_backup( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Restore a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - backup = msg["backup"] - - if msg["ezsp_force_write_eui64"]: - backup.network_info.stack_specific.setdefault("ezsp", {})[ - EZSP_OVERWRITE_EUI64 - ] = True - - # This can take 30-40s - try: - await application_controller.backups.restore_backup(backup) - except ValueError as err: - connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) - else: - connection.send_result(msg[ID]) - - -@callback -def async_load_api(hass: HomeAssistant) -> None: - """Set up the web socket API.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - async def permit(service: ServiceCall) -> None: - """Allow devices to join this network.""" - duration: int = service.data[ATTR_DURATION] - ieee: EUI64 | None = service.data.get(ATTR_IEEE) - src_ieee: EUI64 - code: bytes - if ATTR_SOURCE_IEEE in service.data: - src_ieee = service.data[ATTR_SOURCE_IEEE] - code = service.data[ATTR_INSTALL_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - return - - if ATTR_QR_CODE in service.data: - src_ieee, code = service.data[ATTR_QR_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - return - - if ieee: - _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) - else: - _LOGGER.info("Permitting joins for %ss", duration) - await application_controller.permit(time_s=duration, node=ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] - ) - - async def remove(service: ServiceCall) -> None: - """Remove a node from the network.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = service.data[ATTR_IEEE] - zha_device: ZHADevice | None = zha_gateway.get_device(ieee) - if zha_device is not None and zha_device.is_active_coordinator: - _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) - return - _LOGGER.info("Removing node %s", ieee) - await application_controller.remove(ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] - ) - - async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: - """Set zigbee attribute for cluster on zha entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - attribute: int | str = service.data[ATTR_ATTRIBUTE] - value: int | bool | str = service.data[ATTR_VALUE] - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - response = None - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - response = await zha_device.write_zigbee_attribute( - endpoint_id, - cluster_id, - attribute, - value, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - _LOGGER.debug( - ( - "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" - " [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_ATTRIBUTE, - attribute, - ATTR_VALUE, - value, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, - set_zigbee_cluster_attributes, - schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], - ) - - async def issue_zigbee_cluster_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on ZHA entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - command: int = service.data[ATTR_COMMAND] - command_type: str = service.data[ATTR_COMMAND_TYPE] - args: list | None = service.data.get(ATTR_ARGS) - params: dict | None = service.data.get(ATTR_PARAMS) - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - - await zha_device.issue_cluster_command( - endpoint_id, - cluster_id, - command, - command_type, - args, - params, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - _LOGGER.debug( - ( - "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" - " %s: [%s] %s: [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_COMMAND, - command, - ATTR_COMMAND_TYPE, - command_type, - ATTR_ARGS, - args, - ATTR_PARAMS, - params, - ATTR_MANUFACTURER, - manufacturer, - ) - else: - raise ValueError(f"Device with IEEE {str(ieee)} not found") - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, - issue_zigbee_cluster_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], - ) - - async def issue_zigbee_group_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on a zigbee group.""" - group_id: int = service.data[ATTR_GROUP] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - command: int = service.data[ATTR_COMMAND] - args: list = service.data[ATTR_ARGS] - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - group = zha_gateway.get_group(group_id) - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) - response = None - if group is not None: - cluster = group.endpoint[cluster_id] - response = await cluster.command( - command, *args, manufacturer=manufacturer, expect_reply=True - ) - _LOGGER.debug( - "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_COMMAND, - command, - ATTR_ARGS, - args, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, - issue_zigbee_group_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], - ) - - def _get_ias_wd_channel(zha_device): - """Get the IASWD channel for a device.""" - cluster_channels = { - ch.name: ch - for pool in zha_device.channels.pools - for ch in pool.claimed_channels.values() - } - return cluster_channels.get(CHANNEL_IAS_WD) - - async def warning_device_squawk(service: ServiceCall) -> None: - """Issue the squawk command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_squawk(mode, strobe, level) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] is missing the required IASWD channel!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_SQUAWK, - warning_device_squawk, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], - ) - - async def warning_device_warn(service: ServiceCall) -> None: - """Issue the warning command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] - duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] - intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] - - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_start_warning( - mode, strobe, level, duration, duty_mode, intensity - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] is missing the required IASWD channel!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_WARN, - warning_device_warn, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], - ) - - websocket_api.async_register_command(hass, websocket_permit_devices) - websocket_api.async_register_command(hass, websocket_get_devices) - websocket_api.async_register_command(hass, websocket_get_groupable_devices) - websocket_api.async_register_command(hass, websocket_get_groups) - websocket_api.async_register_command(hass, websocket_get_device) - websocket_api.async_register_command(hass, websocket_get_group) - websocket_api.async_register_command(hass, websocket_add_group) - websocket_api.async_register_command(hass, websocket_remove_groups) - websocket_api.async_register_command(hass, websocket_add_group_members) - websocket_api.async_register_command(hass, websocket_remove_group_members) - websocket_api.async_register_command(hass, websocket_bind_group) - websocket_api.async_register_command(hass, websocket_unbind_group) - websocket_api.async_register_command(hass, websocket_reconfigure_node) - websocket_api.async_register_command(hass, websocket_device_clusters) - websocket_api.async_register_command(hass, websocket_device_cluster_attributes) - websocket_api.async_register_command(hass, websocket_device_cluster_commands) - websocket_api.async_register_command(hass, websocket_read_zigbee_cluster_attributes) - websocket_api.async_register_command(hass, websocket_get_bindable_devices) - websocket_api.async_register_command(hass, websocket_bind_devices) - websocket_api.async_register_command(hass, websocket_unbind_devices) - websocket_api.async_register_command(hass, websocket_update_topology) - websocket_api.async_register_command(hass, websocket_get_configuration) - websocket_api.async_register_command(hass, websocket_update_zha_configuration) - websocket_api.async_register_command(hass, websocket_get_network_settings) - websocket_api.async_register_command(hass, websocket_list_network_backups) - websocket_api.async_register_command(hass, websocket_create_network_backup) - websocket_api.async_register_command(hass, websocket_restore_network_backup) - - -@callback -def async_unload_api(hass: HomeAssistant) -> None: - """Unload the ZHA API.""" - hass.services.async_remove(DOMAIN, SERVICE_PERMIT) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE) - hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN) + return config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 17ec04fa9e..9d40314e06 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -84,7 +84,7 @@ from .const import ( from .helpers import LogMixin, async_get_zha_config_value, convert_to_zcl_values if TYPE_CHECKING: - from ..api import ClusterBinding + from ..websocket_api import ClusterBinding from .gateway import ZHAGateway _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 1bc77d3f36..3f9ada1ed0 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -148,14 +148,8 @@ class ZHAGateway: self._unsubs: list[Callable[[], None]] = [] self.initialized: bool = False - async def async_initialize(self) -> None: - """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) - + def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: + """Get an uninitialized instance of a zigpy `ControllerApplication`.""" radio_type = self.config_entry.data[CONF_RADIO_TYPE] app_controller_cls = RadioType[radio_type].controller @@ -178,7 +172,17 @@ class ZHAGateway: ): app_config[CONF_USE_THREAD] = False - app_config = app_controller_cls.SCHEMA(app_config) + return app_controller_cls, app_controller_cls.SCHEMA(app_config) + + async def async_initialize(self) -> None: + """Initialize controller and connect radio.""" + discovery.PROBE.initialize(self._hass) + discovery.GROUP_PROBE.initialize(self._hass) + + self.ha_device_registry = dr.async_get(self._hass) + self.ha_entity_registry = er.async_get(self._hass) + + app_controller_cls, app_config = self.get_application_controller_data() for attempt in range(STARTUP_RETRIES): try: diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 9867bc5cfb..25a01f45ba 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -12,10 +12,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -from .api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN from .core.channels.manufacturerspecific import AllLEDEffectType, SingleLEDEffectType from .core.const import CHANNEL_IAS_WD, CHANNEL_INOVELLI from .core.helpers import async_get_zha_device +from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN # mypy: disallow-any-generics diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py new file mode 100644 index 0000000000..d2da6af012 --- /dev/null +++ b/homeassistant/components/zha/websocket_api.py @@ -0,0 +1,1541 @@ +"""Web socket API for Zigbee Home Automation devices.""" +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast + +import voluptuous as vol +import zigpy.backups +from zigpy.config.validators import cv_boolean +from zigpy.types.named import EUI64 +from zigpy.zcl.clusters.security import IasAce +import zigpy.zdo.types as zdo_types + +from homeassistant.components import websocket_api +from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, ServiceCall, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service import async_register_admin_service + +from .api import async_get_active_network_settings, async_get_radio_type +from .core.const import ( + ATTR_ARGS, + ATTR_ATTRIBUTE, + ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, + ATTR_COMMAND_TYPE, + ATTR_ENDPOINT_ID, + ATTR_IEEE, + ATTR_LEVEL, + ATTR_MANUFACTURER, + ATTR_MEMBERS, + ATTR_PARAMS, + ATTR_TYPE, + ATTR_VALUE, + ATTR_WARNING_DEVICE_DURATION, + ATTR_WARNING_DEVICE_MODE, + ATTR_WARNING_DEVICE_STROBE, + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, + ATTR_WARNING_DEVICE_STROBE_INTENSITY, + BINDINGS, + CHANNEL_IAS_WD, + CLUSTER_COMMAND_SERVER, + CLUSTER_COMMANDS_CLIENT, + CLUSTER_COMMANDS_SERVER, + CLUSTER_TYPE_IN, + CLUSTER_TYPE_OUT, + CUSTOM_CONFIGURATION, + DATA_ZHA, + DATA_ZHA_GATEWAY, + DOMAIN, + EZSP_OVERWRITE_EUI64, + GROUP_ID, + GROUP_IDS, + GROUP_NAME, + MFG_CLUSTER_ID_START, + WARNING_DEVICE_MODE_EMERGENCY, + WARNING_DEVICE_SOUND_HIGH, + WARNING_DEVICE_SQUAWK_MODE_ARMED, + WARNING_DEVICE_STROBE_HIGH, + WARNING_DEVICE_STROBE_YES, + ZHA_ALARM_OPTIONS, + ZHA_CHANNEL_MSG, + ZHA_CONFIG_SCHEMAS, +) +from .core.gateway import EntityReference +from .core.group import GroupMember +from .core.helpers import ( + async_cluster_exists, + async_is_bindable_target, + cluster_command_schema_to_vol_schema, + convert_install_code, + get_matched_clusters, + qr_to_install_code, +) + +if TYPE_CHECKING: + from homeassistant.components.websocket_api.connection import ActiveConnection + + from .core.device import ZHADevice + from .core.gateway import ZHAGateway + +_LOGGER = logging.getLogger(__name__) + +TYPE = "type" +CLIENT = "client" +ID = "id" +RESPONSE = "response" +DEVICE_INFO = "device_info" + +ATTR_DURATION = "duration" +ATTR_GROUP = "group" +ATTR_IEEE_ADDRESS = "ieee_address" +ATTR_INSTALL_CODE = "install_code" +ATTR_SOURCE_IEEE = "source_ieee" +ATTR_TARGET_IEEE = "target_ieee" +ATTR_QR_CODE = "qr_code" + +SERVICE_PERMIT = "permit" +SERVICE_REMOVE = "remove" +SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" +SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" +SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" +SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" +SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" +SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" +SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" +SERVICE_ZIGBEE_BIND = "service_zigbee_bind" +IEEE_SERVICE = "ieee_based_service" + +IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) + +# typing typevar +_T = TypeVar("_T") + + +def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: + """Wrap value in list if it is provided and not one.""" + if value is None: + return None + return cast("list[_T]", value) if isinstance(value, list) else [value] + + +SERVICE_PERMIT_PARAMS = { + vol.Optional(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional(ATTR_DURATION, default=60): vol.All( + vol.Coerce(int), vol.Range(0, 254) + ), + vol.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, + vol.Inclusive(ATTR_INSTALL_CODE, "install_code"): vol.All( + cv.string, convert_install_code + ), + vol.Exclusive(ATTR_QR_CODE, "install_code"): vol.All(cv.string, qr_to_install_code), +} + +SERVICE_SCHEMAS = { + SERVICE_PERMIT: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + SERVICE_PERMIT_PARAMS, + ) + ), + IEEE_SERVICE: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + {vol.Required(ATTR_IEEE): IEEE_SCHEMA}, + ) + ), + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_ATTRIBUTE): vol.Any(cv.positive_int, str), + vol.Required(ATTR_VALUE): vol.Any(int, cv.boolean, cv.string), + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_WARN: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH + ): cv.positive_int, + } + ), + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Required(ATTR_COMMAND_TYPE): cv.string, + vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, + vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), + cv.deprecated(ATTR_ARGS), + cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), + ), + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( + { + vol.Required(ATTR_GROUP): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), +} + + +class ClusterBinding(NamedTuple): + """Describes a cluster binding.""" + + name: str + type: str + id: int + endpoint_id: int + + +def _cv_group_member(value: dict[str, Any]) -> GroupMember: + """Transform a group member.""" + return GroupMember( + ieee=value[ATTR_IEEE], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding: + """Transform a cluster binding.""" + return ClusterBinding( + name=value[ATTR_NAME], + type=value[ATTR_TYPE], + id=value[ATTR_ID], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup: + """Transform a zigpy network backup.""" + + try: + return zigpy.backups.NetworkBackup.from_dict(value) + except ValueError as err: + raise vol.Invalid(str(err)) from err + + +GROUP_MEMBER_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_group_member, +) + + +CLUSTER_BINDING_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_TYPE): cv.string, + vol.Required(ATTR_ID): vol.Coerce(int), + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_cluster_binding, +) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "zha/devices/permit", + **SERVICE_PERMIT_PARAMS, + } +) +@websocket_api.async_response +async def websocket_permit_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Permit ZHA zigbee devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + duration: int = msg[ATTR_DURATION] + ieee: EUI64 | None = msg.get(ATTR_IEEE) + + async def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg["id"], data)) + + remove_dispatcher_function = async_dispatcher_connect( + hass, "zha_gateway_message", forward_messages + ) + + @callback + def async_cleanup() -> None: + """Remove signal listener and turn off debug mode.""" + zha_gateway.async_disable_debug_mode() + remove_dispatcher_function() + + connection.subscriptions[msg["id"]] = async_cleanup + zha_gateway.async_enable_debug_mode() + src_ieee: EUI64 + code: bytes + if ATTR_SOURCE_IEEE in msg: + src_ieee = msg[ATTR_SOURCE_IEEE] + code = msg[ATTR_INSTALL_CODE] + _LOGGER.debug("Allowing join for %s device with install code", src_ieee) + await zha_gateway.application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + elif ATTR_QR_CODE in msg: + src_ieee, code = msg[ATTR_QR_CODE] + _LOGGER.debug("Allowing join for %s device with install code", src_ieee) + await zha_gateway.application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + else: + await zha_gateway.application_controller.permit(time_s=duration, node=ieee) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices"}) +@websocket_api.async_response +async def websocket_get_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + devices = [device.zha_device_info for device in zha_gateway.devices.values()] + connection.send_result(msg[ID], devices) + + +@callback +def _get_entity_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.name if entry else None + + +@callback +def _get_entity_original_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.original_name if entry else None + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) +@websocket_api.async_response +async def websocket_get_groupable_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices that can be grouped.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + devices = [device for device in zha_gateway.devices.values() if device.is_groupable] + groupable_devices = [] + + for device in devices: + entity_refs = zha_gateway.device_registry[device.ieee] + for ep_id in device.async_get_groupable_endpoints(): + groupable_devices.append( + { + "endpoint_id": ep_id, + "entities": [ + { + "name": _get_entity_name(zha_gateway, entity_ref), + "original_name": _get_entity_original_name( + zha_gateway, entity_ref + ), + } + for entity_ref in entity_refs + if list(entity_ref.cluster_channels.values())[ + 0 + ].cluster.endpoint.endpoint_id + == ep_id + ], + "device": device.zha_device_info, + } + ) + + connection.send_result(msg[ID], groupable_devices) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"}) +@websocket_api.async_response +async def websocket_get_groups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA groups.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + groups = [group.group_info for group in zha_gateway.groups.values()] + connection.send_result(msg[ID], groups) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/device", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_get_device( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + + if not (zha_device := zha_gateway.devices.get(ieee)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Device not found" + ) + ) + return + + device_info = zha_device.zha_device_info + connection.send_result(msg[ID], device_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group", + vol.Required(GROUP_ID): cv.positive_int, + } +) +@websocket_api.async_response +async def websocket_get_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + group_info = zha_group.group_info + connection.send_result(msg[ID], group_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/add", + vol.Required(GROUP_NAME): cv.string, + vol.Optional(GROUP_ID): cv.positive_int, + vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_add_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Add a new ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_name: str = msg[GROUP_NAME] + group_id: int | None = msg.get(GROUP_ID) + members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) + group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) + assert group + connection.send_result(msg[ID], group.group_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/remove", + vol.Required(GROUP_IDS): vol.All(cv.ensure_list, [cv.positive_int]), + } +) +@websocket_api.async_response +async def websocket_remove_groups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove the specified ZHA groups.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_ids: list[int] = msg[GROUP_IDS] + + if len(group_ids) > 1: + tasks = [] + for group_id in group_ids: + tasks.append(zha_gateway.async_remove_zigpy_group(group_id)) + await asyncio.gather(*tasks) + else: + await zha_gateway.async_remove_zigpy_group(group_ids[0]) + ret_groups = [group.group_info for group in zha_gateway.groups.values()] + connection.send_result(msg[ID], ret_groups) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/members/add", + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_add_group_members( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Add members to a ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + await zha_group.async_add_members(members) + ret_group = zha_group.group_info + connection.send_result(msg[ID], ret_group) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/members/remove", + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_remove_group_members( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove members from a ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + await zha_group.async_remove_members(members) + ret_group = zha_group.group_info + connection.send_result(msg[ID], ret_group) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/reconfigure", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_reconfigure_node( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Reconfigure a ZHA nodes entities by its ieee address.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + device: ZHADevice | None = zha_gateway.get_device(ieee) + + async def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg["id"], data)) + + remove_dispatcher_function = async_dispatcher_connect( + hass, ZHA_CHANNEL_MSG, forward_messages + ) + + @callback + def async_cleanup() -> None: + """Remove signal listener.""" + remove_dispatcher_function() + + connection.subscriptions[msg["id"]] = async_cleanup + + _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) + assert device + hass.async_create_task(device.async_configure()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/topology/update", + } +) +@websocket_api.async_response +async def websocket_update_topology( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Update the ZHA network topology.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + hass.async_create_task(zha_gateway.application_controller.topology.scan()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_device_clusters( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of device clusters.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + zha_device = zha_gateway.get_device(ieee) + response_clusters = [] + if zha_device is not None: + clusters_by_endpoint = zha_device.async_get_clusters() + for ep_id, clusters in clusters_by_endpoint.items(): + for c_id, cluster in clusters[CLUSTER_TYPE_IN].items(): + response_clusters.append( + { + TYPE: CLUSTER_TYPE_IN, + ID: c_id, + ATTR_NAME: cluster.__class__.__name__, + "endpoint_id": ep_id, + } + ) + for c_id, cluster in clusters[CLUSTER_TYPE_OUT].items(): + response_clusters.append( + { + TYPE: CLUSTER_TYPE_OUT, + ID: c_id, + ATTR_NAME: cluster.__class__.__name__, + "endpoint_id": ep_id, + } + ) + + connection.send_result(msg[ID], response_clusters) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/attributes", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + } +) +@websocket_api.async_response +async def websocket_device_cluster_attributes( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of cluster attributes.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + cluster_attributes: list[dict[str, Any]] = [] + zha_device = zha_gateway.get_device(ieee) + attributes = None + if zha_device is not None: + attributes = zha_device.async_get_cluster_attributes( + endpoint_id, cluster_id, cluster_type + ) + if attributes is not None: + for attr_id, attr in attributes.items(): + cluster_attributes.append({ID: attr_id, ATTR_NAME: attr.name}) + _LOGGER.debug( + "Requested attributes for: %s: %s, %s: '%s', %s: %s, %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + RESPONSE, + cluster_attributes, + ) + + connection.send_result(msg[ID], cluster_attributes) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/commands", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + } +) +@websocket_api.async_response +async def websocket_device_cluster_commands( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of cluster commands.""" + import voluptuous_serialize # pylint: disable=import-outside-toplevel + + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + zha_device = zha_gateway.get_device(ieee) + cluster_commands: list[dict[str, Any]] = [] + commands = None + if zha_device is not None: + commands = zha_device.async_get_cluster_commands( + endpoint_id, cluster_id, cluster_type + ) + + if commands is not None: + for cmd_id, cmd in commands[CLUSTER_COMMANDS_CLIENT].items(): + cluster_commands.append( + { + TYPE: CLIENT, + ID: cmd_id, + ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), + } + ) + for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): + cluster_commands.append( + { + TYPE: CLUSTER_COMMAND_SERVER, + ID: cmd_id, + ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), + } + ) + _LOGGER.debug( + "Requested commands for: %s: %s, %s: '%s', %s: %s, %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + RESPONSE, + cluster_commands, + ) + + connection.send_result(msg[ID], cluster_commands) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/attributes/value", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + vol.Required(ATTR_ATTRIBUTE): int, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } +) +@websocket_api.async_response +async def websocket_read_zigbee_cluster_attributes( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Read zigbee attribute for cluster on ZHA entity.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + attribute: int = msg[ATTR_ATTRIBUTE] + manufacturer: int | None = msg.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + success = {} + failure = {} + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + cluster = zha_device.async_get_cluster( + endpoint_id, cluster_id, cluster_type=cluster_type + ) + success, failure = await cluster.read_attributes( + [attribute], allow_cache=False, only_cache=False, manufacturer=manufacturer + ) + _LOGGER.debug( + ( + "Read attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s]," + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + str(success.get(attribute)), + "failure", + failure, + ) + connection.send_result(msg[ID], str(success.get(attribute))) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/bindable", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_get_bindable_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_IEEE] + source_device = zha_gateway.get_device(source_ieee) + + devices = [ + device.zha_device_info + for device in zha_gateway.devices.values() + if async_is_bindable_target(source_device, device) + ] + + _LOGGER.debug( + "Get bindable devices: %s: [%s], %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + "bindable devices", + devices, + ) + + connection.send_message(websocket_api.result_message(msg[ID], devices)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/bind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_bind_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Bind_req + ) + _LOGGER.info( + "Devices bound: %s: [%s] %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + ATTR_TARGET_IEEE, + target_ieee, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/unbind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_unbind_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove a direct binding between devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Unbind_req + ) + _LOGGER.info( + "Devices un-bound: %s: [%s] %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + ATTR_TARGET_IEEE, + target_ieee, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/bind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_bind_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind a device to a group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + assert source_device + await source_device.async_bind_to_group(group_id, bindings) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/unbind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_unbind_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Unbind a device from a group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + assert source_device + await source_device.async_unbind_from_group(group_id, bindings) + + +async def async_binding_operation( + zha_gateway: ZHAGateway, + source_ieee: EUI64, + target_ieee: EUI64, + operation: zdo_types.ZDOCmd, +) -> None: + """Create or remove a direct zigbee binding between 2 devices.""" + + source_device = zha_gateway.get_device(source_ieee) + target_device = zha_gateway.get_device(target_ieee) + + assert source_device + assert target_device + clusters_to_bind = await get_matched_clusters(source_device, target_device) + + zdo = source_device.device.zdo + bind_tasks = [] + for binding_pair in clusters_to_bind: + op_msg = "cluster: %s %s --> [%s]" + op_params = ( + binding_pair.source_cluster.cluster_id, + operation.name, + target_ieee, + ) + zdo.debug(f"processing {op_msg}", *op_params) + + bind_tasks.append( + ( + zdo.request( + operation, + source_device.ieee, + binding_pair.source_cluster.endpoint.endpoint_id, + binding_pair.source_cluster.cluster_id, + binding_pair.destination_address, + ), + op_msg, + op_params, + ) + ) + res = await asyncio.gather(*(t[0] for t in bind_tasks), return_exceptions=True) + for outcome, log_msg in zip(res, bind_tasks): + if isinstance(outcome, Exception): + fmt = f"{log_msg[1]} failed: %s" + else: + fmt = f"{log_msg[1]} completed: %s" + zdo.debug(fmt, *(log_msg[2] + (outcome,))) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/configuration"}) +@websocket_api.async_response +async def websocket_get_configuration( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA configuration.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + import voluptuous_serialize # pylint: disable=import-outside-toplevel + + def custom_serializer(schema: Any) -> Any: + """Serialize additional types for voluptuous_serialize.""" + if schema is cv_boolean: + return {"type": "bool"} + if schema is vol.Schema: + return voluptuous_serialize.convert( + schema, custom_serializer=custom_serializer + ) + + return cv.custom_serializer(schema) + + data: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} + for section, schema in ZHA_CONFIG_SCHEMAS.items(): + if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( + hass, IasAce.cluster_id + ): + continue + data["schemas"][section] = voluptuous_serialize.convert( + schema, custom_serializer=custom_serializer + ) + data["data"][section] = zha_gateway.config_entry.options.get( + CUSTOM_CONFIGURATION, {} + ).get(section, {}) + + # send default values for unconfigured options + for entry in data["schemas"][section]: + if data["data"][section].get(entry["name"]) is None: + data["data"][section][entry["name"]] = entry["default"] + + connection.send_result(msg[ID], data) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/configuration/update", + vol.Required("data"): ZHA_CONFIG_SCHEMAS, + } +) +@websocket_api.async_response +async def websocket_update_zha_configuration( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Update the ZHA configuration.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + options = zha_gateway.config_entry.options + data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} + + for section, schema in ZHA_CONFIG_SCHEMAS.items(): + for entry in schema.schema: + # remove options that match defaults + if ( + data_to_save[CUSTOM_CONFIGURATION].get(section, {}).get(entry) + == entry.default() + ): + data_to_save[CUSTOM_CONFIGURATION][section].pop(entry) + # remove entire section block if empty + if ( + not data_to_save[CUSTOM_CONFIGURATION].get(section) + and section in data_to_save[CUSTOM_CONFIGURATION] + ): + data_to_save[CUSTOM_CONFIGURATION].pop(section) + + # remove entire custom_configuration block if empty + if ( + not data_to_save.get(CUSTOM_CONFIGURATION) + and CUSTOM_CONFIGURATION in data_to_save + ): + data_to_save.pop(CUSTOM_CONFIGURATION) + + _LOGGER.info( + "Updating ZHA custom configuration options from %s to %s", + options, + data_to_save, + ) + + hass.config_entries.async_update_entry( + zha_gateway.config_entry, options=data_to_save + ) + status = await hass.config_entries.async_reload(zha_gateway.config_entry.entry_id) + connection.send_result(msg[ID], status) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/settings"}) +@websocket_api.async_response +async def websocket_get_network_settings( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA network settings.""" + backup = async_get_active_network_settings(hass) + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + connection.send_result( + msg[ID], + { + "radio_type": async_get_radio_type(hass, zha_gateway.config_entry).name, + "settings": backup.as_dict(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"}) +@websocket_api.async_response +async def websocket_list_network_backups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA network settings.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + # Serialize known backups + connection.send_result( + msg[ID], [backup.as_dict() for backup in application_controller.backups] + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"}) +@websocket_api.async_response +async def websocket_create_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Create a ZHA network backup.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + # This can take 5-30s + backup = await application_controller.backups.create_backup(load_devices=True) + connection.send_result( + msg[ID], + { + "backup": backup.as_dict(), + "is_complete": backup.is_complete(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/network/backups/restore", + vol.Required("backup"): _cv_zigpy_network_backup, + vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean, + } +) +@websocket_api.async_response +async def websocket_restore_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Restore a ZHA network backup.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + backup = msg["backup"] + + if msg["ezsp_force_write_eui64"]: + backup.network_info.stack_specific.setdefault("ezsp", {})[ + EZSP_OVERWRITE_EUI64 + ] = True + + # This can take 30-40s + try: + await application_controller.backups.restore_backup(backup) + except ValueError as err: + connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) + else: + connection.send_result(msg[ID]) + + +@callback +def async_load_api(hass: HomeAssistant) -> None: + """Set up the web socket API.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + async def permit(service: ServiceCall) -> None: + """Allow devices to join this network.""" + duration: int = service.data[ATTR_DURATION] + ieee: EUI64 | None = service.data.get(ATTR_IEEE) + src_ieee: EUI64 + code: bytes + if ATTR_SOURCE_IEEE in service.data: + src_ieee = service.data[ATTR_SOURCE_IEEE] + code = service.data[ATTR_INSTALL_CODE] + _LOGGER.info("Allowing join for %s device with install code", src_ieee) + await application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + return + + if ATTR_QR_CODE in service.data: + src_ieee, code = service.data[ATTR_QR_CODE] + _LOGGER.info("Allowing join for %s device with install code", src_ieee) + await application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + return + + if ieee: + _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) + else: + _LOGGER.info("Permitting joins for %ss", duration) + await application_controller.permit(time_s=duration, node=ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] + ) + + async def remove(service: ServiceCall) -> None: + """Remove a node from the network.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = service.data[ATTR_IEEE] + zha_device: ZHADevice | None = zha_gateway.get_device(ieee) + if zha_device is not None and zha_device.is_active_coordinator: + _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) + return + _LOGGER.info("Removing node %s", ieee) + await application_controller.remove(ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] + ) + + async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: + """Set zigbee attribute for cluster on zha entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + attribute: int | str = service.data[ATTR_ATTRIBUTE] + value: int | bool | str = service.data[ATTR_VALUE] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + response = None + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + response = await zha_device.write_zigbee_attribute( + endpoint_id, + cluster_id, + attribute, + value, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" + " [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_VALUE, + value, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + set_zigbee_cluster_attributes, + schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], + ) + + async def issue_zigbee_cluster_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on ZHA entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + command: int = service.data[ATTR_COMMAND] + command_type: str = service.data[ATTR_COMMAND_TYPE] + args: list | None = service.data.get(ATTR_ARGS) + params: dict | None = service.data.get(ATTR_PARAMS) + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + + await zha_device.issue_cluster_command( + endpoint_id, + cluster_id, + command, + command_type, + args, + params, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s] %s: [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_COMMAND, + command, + ATTR_COMMAND_TYPE, + command_type, + ATTR_ARGS, + args, + ATTR_PARAMS, + params, + ATTR_MANUFACTURER, + manufacturer, + ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + issue_zigbee_cluster_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], + ) + + async def issue_zigbee_group_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on a zigbee group.""" + group_id: int = service.data[ATTR_GROUP] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + command: int = service.data[ATTR_COMMAND] + args: list = service.data[ATTR_ARGS] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + group = zha_gateway.get_group(group_id) + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) + response = None + if group is not None: + cluster = group.endpoint[cluster_id] + response = await cluster.command( + command, *args, manufacturer=manufacturer, expect_reply=True + ) + _LOGGER.debug( + "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_COMMAND, + command, + ATTR_ARGS, + args, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, + issue_zigbee_group_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], + ) + + def _get_ias_wd_channel(zha_device): + """Get the IASWD channel for a device.""" + cluster_channels = { + ch.name: ch + for pool in zha_device.channels.pools + for ch in pool.claimed_channels.values() + } + return cluster_channels.get(CHANNEL_IAS_WD) + + async def warning_device_squawk(service: ServiceCall) -> None: + """Issue the squawk command for an IAS warning device.""" + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if channel := _get_ias_wd_channel(zha_device): + await channel.issue_squawk(mode, strobe, level) + else: + _LOGGER.error( + "Squawking IASWD: %s: [%s] is missing the required IASWD channel!", + ATTR_IEEE, + str(ieee), + ) + else: + _LOGGER.error( + "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) + ) + _LOGGER.debug( + "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_IEEE, + str(ieee), + ATTR_WARNING_DEVICE_MODE, + mode, + ATTR_WARNING_DEVICE_STROBE, + strobe, + ATTR_LEVEL, + level, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_SQUAWK, + warning_device_squawk, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], + ) + + async def warning_device_warn(service: ServiceCall) -> None: + """Issue the warning command for an IAS warning device.""" + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] + duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] + intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] + + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if channel := _get_ias_wd_channel(zha_device): + await channel.issue_start_warning( + mode, strobe, level, duration, duty_mode, intensity + ) + else: + _LOGGER.error( + "Warning IASWD: %s: [%s] is missing the required IASWD channel!", + ATTR_IEEE, + str(ieee), + ) + else: + _LOGGER.error( + "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) + ) + _LOGGER.debug( + "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_IEEE, + str(ieee), + ATTR_WARNING_DEVICE_MODE, + mode, + ATTR_WARNING_DEVICE_STROBE, + strobe, + ATTR_LEVEL, + level, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_WARN, + warning_device_warn, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], + ) + + websocket_api.async_register_command(hass, websocket_permit_devices) + websocket_api.async_register_command(hass, websocket_get_devices) + websocket_api.async_register_command(hass, websocket_get_groupable_devices) + websocket_api.async_register_command(hass, websocket_get_groups) + websocket_api.async_register_command(hass, websocket_get_device) + websocket_api.async_register_command(hass, websocket_get_group) + websocket_api.async_register_command(hass, websocket_add_group) + websocket_api.async_register_command(hass, websocket_remove_groups) + websocket_api.async_register_command(hass, websocket_add_group_members) + websocket_api.async_register_command(hass, websocket_remove_group_members) + websocket_api.async_register_command(hass, websocket_bind_group) + websocket_api.async_register_command(hass, websocket_unbind_group) + websocket_api.async_register_command(hass, websocket_reconfigure_node) + websocket_api.async_register_command(hass, websocket_device_clusters) + websocket_api.async_register_command(hass, websocket_device_cluster_attributes) + websocket_api.async_register_command(hass, websocket_device_cluster_commands) + websocket_api.async_register_command(hass, websocket_read_zigbee_cluster_attributes) + websocket_api.async_register_command(hass, websocket_get_bindable_devices) + websocket_api.async_register_command(hass, websocket_bind_devices) + websocket_api.async_register_command(hass, websocket_unbind_devices) + websocket_api.async_register_command(hass, websocket_update_topology) + websocket_api.async_register_command(hass, websocket_get_configuration) + websocket_api.async_register_command(hass, websocket_update_zha_configuration) + websocket_api.async_register_command(hass, websocket_get_network_settings) + websocket_api.async_register_command(hass, websocket_list_network_backups) + websocket_api.async_register_command(hass, websocket_create_network_backup) + websocket_api.async_register_command(hass, websocket_restore_network_backup) + + +@callback +def async_unload_api(hass: HomeAssistant) -> None: + """Unload the ZHA API.""" + hass.services.async_remove(DOMAIN, SERVICE_PERMIT) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN) diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 8610c8cd7c..0d03b62bf8 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -1,842 +1,91 @@ """Test ZHA API.""" -from binascii import unhexlify -from copy import deepcopy -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest -import voluptuous as vol -import zigpy.backups -import zigpy.profiles.zha -import zigpy.types -import zigpy.zcl.clusters.general as general -import zigpy.zcl.clusters.security as security +import zigpy.state -from homeassistant.components.websocket_api import const -from homeassistant.components.zha import DOMAIN -from homeassistant.components.zha.api import ( - ATTR_DURATION, - ATTR_INSTALL_CODE, - ATTR_QR_CODE, - ATTR_SOURCE_IEEE, - ID, - SERVICE_PERMIT, - TYPE, - async_load_api, -) -from homeassistant.components.zha.core.const import ( - ATTR_CLUSTER_ID, - ATTR_CLUSTER_TYPE, - ATTR_ENDPOINT_ID, - ATTR_ENDPOINT_NAMES, - ATTR_IEEE, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NEIGHBORS, - ATTR_QUIRK_APPLIED, - CLUSTER_TYPE_IN, - DATA_ZHA, - DATA_ZHA_GATEWAY, - EZSP_OVERWRITE_EUI64, - GROUP_ID, - GROUP_IDS, - GROUP_NAME, -) -from homeassistant.const import ATTR_NAME, Platform -from homeassistant.core import Context, HomeAssistant - -from .conftest import ( - FIXTURE_GRP_ID, - FIXTURE_GRP_NAME, - SIG_EP_INPUT, - SIG_EP_OUTPUT, - SIG_EP_PROFILE, - SIG_EP_TYPE, -) -from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS - -from tests.common import MockUser - -IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" -IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" +from homeassistant.components import zha +from homeassistant.components.zha import api +from homeassistant.components.zha.core.const import RadioType @pytest.fixture(autouse=True) def required_platform_only(): """Only set up the required and required base platforms to speed up tests.""" - with patch( - "homeassistant.components.zha.PLATFORMS", - ( - Platform.ALARM_CONTROL_PANEL, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ), - ): + with patch("homeassistant.components.zha.PLATFORMS", ()): yield -@pytest.fixture -async def device_switch(hass, zigpy_device_mock, zha_device_joined): - """Test ZHA switch platform.""" +async def test_async_get_network_settings_active(hass, setup_zha): + """Test reading settings with an active ZHA installation.""" + await setup_zha() - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [general.OnOff.cluster_id, general.Basic.cluster_id], - SIG_EP_OUTPUT: [], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - }, - ieee=IEEE_SWITCH_DEVICE, - ) - zha_device = await zha_device_joined(zigpy_device) - zha_device.available = True - return zha_device + settings = await api.async_get_network_settings(hass) + assert settings.network_info.channel == 15 -@pytest.fixture -async def device_ias_ace(hass, zigpy_device_mock, zha_device_joined): - """Test alarm control panel device.""" +async def test_async_get_network_settings_inactive( + hass, setup_zha, zigpy_app_controller +): + """Test reading settings with an inactive ZHA installation.""" + await setup_zha() - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [security.IasAce.cluster_id], - SIG_EP_OUTPUT: [], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - }, - ) - zha_device = await zha_device_joined(zigpy_device) - zha_device.available = True - return zha_device + gateway = api._get_gateway(hass) + await zha.async_unload_entry(hass, gateway.config_entry) - -@pytest.fixture -async def device_groupable(hass, zigpy_device_mock, zha_device_joined): - """Test ZHA light platform.""" - - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [ - general.OnOff.cluster_id, - general.Basic.cluster_id, - general.Groups.cluster_id, - ], - SIG_EP_OUTPUT: [], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - }, - ieee=IEEE_GROUPABLE_DEVICE, - ) - zha_device = await zha_device_joined(zigpy_device) - zha_device.available = True - return zha_device - - -@pytest.fixture -async def zha_client(hass, hass_ws_client, device_switch, device_groupable): - """Get ZHA WebSocket client.""" - - # load the ZHA API - async_load_api(hass) - return await hass_ws_client(hass) - - -async def test_device_clusters(hass: HomeAssistant, zha_client) -> None: - """Test getting device cluster info.""" - await zha_client.send_json( - {ID: 5, TYPE: "zha/devices/clusters", ATTR_IEEE: IEEE_SWITCH_DEVICE} - ) - - msg = await zha_client.receive_json() - - assert len(msg["result"]) == 2 - - cluster_infos = sorted(msg["result"], key=lambda k: k[ID]) - - cluster_info = cluster_infos[0] - assert cluster_info[TYPE] == CLUSTER_TYPE_IN - assert cluster_info[ID] == 0 - assert cluster_info[ATTR_NAME] == "Basic" - - cluster_info = cluster_infos[1] - assert cluster_info[TYPE] == CLUSTER_TYPE_IN - assert cluster_info[ID] == 6 - assert cluster_info[ATTR_NAME] == "OnOff" - - -async def test_device_cluster_attributes(zha_client) -> None: - """Test getting device cluster attributes.""" - await zha_client.send_json( - { - ID: 5, - TYPE: "zha/devices/clusters/attributes", - ATTR_ENDPOINT_ID: 1, - ATTR_IEEE: IEEE_SWITCH_DEVICE, - ATTR_CLUSTER_ID: 6, - ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, - } - ) - - msg = await zha_client.receive_json() - - attributes = msg["result"] - assert len(attributes) == 7 - - for attribute in attributes: - assert attribute[ID] is not None - assert attribute[ATTR_NAME] is not None - - -async def test_device_cluster_commands(zha_client) -> None: - """Test getting device cluster commands.""" - await zha_client.send_json( - { - ID: 5, - TYPE: "zha/devices/clusters/commands", - ATTR_ENDPOINT_ID: 1, - ATTR_IEEE: IEEE_SWITCH_DEVICE, - ATTR_CLUSTER_ID: 6, - ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, - } - ) - - msg = await zha_client.receive_json() - - commands = msg["result"] - assert len(commands) == 6 - - for command in commands: - assert command[ID] is not None - assert command[ATTR_NAME] is not None - assert command[TYPE] is not None - - -async def test_list_devices(zha_client) -> None: - """Test getting ZHA devices.""" - await zha_client.send_json({ID: 5, TYPE: "zha/devices"}) - - msg = await zha_client.receive_json() - - devices = msg["result"] - assert len(devices) == 2 - - msg_id = 100 - for device in devices: - msg_id += 1 - assert device[ATTR_IEEE] is not None - assert device[ATTR_MANUFACTURER] is not None - assert device[ATTR_MODEL] is not None - assert device[ATTR_NAME] is not None - assert device[ATTR_QUIRK_APPLIED] is not None - assert device["entities"] is not None - assert device[ATTR_NEIGHBORS] is not None - assert device[ATTR_ENDPOINT_NAMES] is not None - - for entity_reference in device["entities"]: - assert entity_reference[ATTR_NAME] is not None - assert entity_reference["entity_id"] is not None - - await zha_client.send_json( - {ID: msg_id, TYPE: "zha/device", ATTR_IEEE: device[ATTR_IEEE]} - ) - msg = await zha_client.receive_json() - device2 = msg["result"] - assert device == device2 - - -async def test_get_zha_config(zha_client) -> None: - """Test getting ZHA custom configuration.""" - await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) - - msg = await zha_client.receive_json() - - configuration = msg["result"] - assert configuration == BASE_CUSTOM_CONFIGURATION - - -async def test_get_zha_config_with_alarm( - hass: HomeAssistant, zha_client, device_ias_ace -) -> None: - """Test getting ZHA custom configuration.""" - await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) - - msg = await zha_client.receive_json() - - configuration = msg["result"] - assert configuration == CONFIG_WITH_ALARM_OPTIONS - - # test that the alarm options are not in the config when we remove the device - device_ias_ace.gateway.device_removed(device_ias_ace.device) - await hass.async_block_till_done() - await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) - - msg = await zha_client.receive_json() - - configuration = msg["result"] - assert configuration == BASE_CUSTOM_CONFIGURATION - - -async def test_update_zha_config(zha_client, zigpy_app_controller) -> None: - """Test updating ZHA custom configuration.""" - - configuration = deepcopy(CONFIG_WITH_ALARM_OPTIONS) - configuration["data"]["zha_options"]["default_light_transition"] = 10 + zigpy_app_controller.state.network_info.channel = 20 with patch( - "bellows.zigbee.application.ControllerApplication.new", + "bellows.zigbee.application.ControllerApplication.__new__", return_value=zigpy_app_controller, ): - await zha_client.send_json( - {ID: 5, TYPE: "zha/configuration/update", "data": configuration["data"]} - ) - msg = await zha_client.receive_json() - assert msg["success"] + settings = await api.async_get_network_settings(hass) - await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) - msg = await zha_client.receive_json() - configuration = msg["result"] - assert configuration == configuration + assert len(zigpy_app_controller._load_db.mock_calls) == 1 + assert len(zigpy_app_controller.start_network.mock_calls) == 0 + + assert settings.network_info.channel == 20 -async def test_device_not_found(zha_client) -> None: - """Test not found response from get device API.""" - await zha_client.send_json( - {ID: 6, TYPE: "zha/device", ATTR_IEEE: "28:6d:97:00:01:04:11:8c"} - ) - msg = await zha_client.receive_json() - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_NOT_FOUND - - -async def test_list_groups(zha_client) -> None: - """Test getting ZHA zigbee groups.""" - await zha_client.send_json({ID: 7, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 1 - - for group in groups: - assert group["group_id"] == FIXTURE_GRP_ID - assert group["name"] == FIXTURE_GRP_NAME - assert group["members"] == [] - - -async def test_get_group(zha_client) -> None: - """Test getting a specific ZHA zigbee group.""" - await zha_client.send_json({ID: 8, TYPE: "zha/group", GROUP_ID: FIXTURE_GRP_ID}) - - msg = await zha_client.receive_json() - assert msg["id"] == 8 - assert msg["type"] == const.TYPE_RESULT - - group = msg["result"] - assert group is not None - assert group["group_id"] == FIXTURE_GRP_ID - assert group["name"] == FIXTURE_GRP_NAME - assert group["members"] == [] - - -async def test_get_group_not_found(zha_client) -> None: - """Test not found response from get group API.""" - await zha_client.send_json({ID: 9, TYPE: "zha/group", GROUP_ID: 1_234_567}) - - msg = await zha_client.receive_json() - - assert msg["id"] == 9 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_NOT_FOUND - - -async def test_list_groupable_devices(zha_client, device_groupable) -> None: - """Test getting ZHA devices that have a group cluster.""" - - await zha_client.send_json({ID: 10, TYPE: "zha/devices/groupable"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 10 - assert msg["type"] == const.TYPE_RESULT - - device_endpoints = msg["result"] - assert len(device_endpoints) == 1 - - for endpoint in device_endpoints: - assert endpoint["device"][ATTR_IEEE] == "01:2d:6f:00:0a:90:69:e8" - assert endpoint["device"][ATTR_MANUFACTURER] is not None - assert endpoint["device"][ATTR_MODEL] is not None - assert endpoint["device"][ATTR_NAME] is not None - assert endpoint["device"][ATTR_QUIRK_APPLIED] is not None - assert endpoint["device"]["entities"] is not None - assert endpoint["endpoint_id"] is not None - assert endpoint["entities"] is not None - - for entity_reference in endpoint["device"]["entities"]: - assert entity_reference[ATTR_NAME] is not None - assert entity_reference["entity_id"] is not None - - for entity_reference in endpoint["entities"]: - assert entity_reference["original_name"] is not None - - # Make sure there are no groupable devices when the device is unavailable - # Make device unavailable - device_groupable.available = False - - await zha_client.send_json({ID: 11, TYPE: "zha/devices/groupable"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 11 - assert msg["type"] == const.TYPE_RESULT - - device_endpoints = msg["result"] - assert len(device_endpoints) == 0 - - -async def test_add_group(zha_client) -> None: - """Test adding and getting a new ZHA zigbee group.""" - await zha_client.send_json({ID: 12, TYPE: "zha/group/add", GROUP_NAME: "new_group"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 12 - assert msg["type"] == const.TYPE_RESULT - - added_group = msg["result"] - - assert added_group["name"] == "new_group" - assert added_group["members"] == [] - - await zha_client.send_json({ID: 13, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 13 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 2 - - for group in groups: - assert group["name"] == FIXTURE_GRP_NAME or group["name"] == "new_group" - - -async def test_remove_group(zha_client) -> None: - """Test removing a new ZHA zigbee group.""" - - await zha_client.send_json({ID: 14, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 1 - - await zha_client.send_json( - {ID: 15, TYPE: "zha/group/remove", GROUP_IDS: [FIXTURE_GRP_ID]} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 15 - assert msg["type"] == const.TYPE_RESULT - - groups_remaining = msg["result"] - assert len(groups_remaining) == 0 - - await zha_client.send_json({ID: 16, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 16 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 0 - - -@pytest.fixture -async def app_controller(hass, setup_zha): - """Fixture for zigpy Application Controller.""" +async def test_async_get_network_settings_missing( + hass, setup_zha, zigpy_app_controller +): + """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() - controller = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].application_controller - p1 = patch.object(controller, "permit") - p2 = patch.object(controller, "permit_with_key", new=AsyncMock()) - with p1, p2: - yield controller + + gateway = api._get_gateway(hass) + await zha.async_unload_entry(hass, gateway.config_entry) + + # Network settings were never loaded for whatever reason + zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() + zigpy_app_controller.state.node_info = zigpy.state.NodeInfo() + + with patch( + "bellows.zigbee.application.ControllerApplication.__new__", + return_value=zigpy_app_controller, + ): + settings = await api.async_get_network_settings(hass) + + assert settings is None -@pytest.mark.parametrize( - ("params", "duration", "node"), - ( - ({}, 60, None), - ({ATTR_DURATION: 30}, 30, None), - ( - {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, - 33, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), - ), - ( - {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, - 60, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), - ), - ), -) -async def test_permit_ha12( - hass: HomeAssistant, - app_controller, - hass_admin_user: MockUser, - params, - duration, - node, -) -> None: - """Test permit service.""" - - await hass.services.async_call( - DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) - ) - assert app_controller.permit.await_count == 1 - assert app_controller.permit.await_args[1]["time_s"] == duration - assert app_controller.permit.await_args[1]["node"] == node - assert app_controller.permit_with_key.call_count == 0 +async def test_async_get_network_settings_failure(hass): + """Test reading settings with no ZHA config entries and no database.""" + with pytest.raises(ValueError): + await api.async_get_network_settings(hass) -IC_TEST_PARAMS = ( - ( - { - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", - }, - zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), - ( - { - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_INSTALL_CODE: "52797BF4A5084DAA8E1712B61741CA024051", - }, - zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), -) +async def test_async_get_radio_type_active(hass, setup_zha): + """Test reading the radio type with an active ZHA installation.""" + await setup_zha() + + radio_type = api.async_get_radio_type(hass) + assert radio_type == RadioType.ezsp -@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_TEST_PARAMS) -async def test_permit_with_install_code( - hass: HomeAssistant, - app_controller, - hass_admin_user: MockUser, - params, - src_ieee, - code, -) -> None: - """Test permit service with install code.""" +async def test_async_get_radio_path_active(hass, setup_zha): + """Test reading the radio path with an active ZHA installation.""" + await setup_zha() - await hass.services.async_call( - DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) - ) - assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code - - -IC_FAIL_PARAMS = ( - { - # wrong install code - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4052", - }, - # incorrect service params - {ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051"}, - {ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE}, - { - # incorrect service params - ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", - ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", - }, - { - # incorrect service params - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", - }, - { - # good regex match, but bad code - ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024052" - }, - { - # good aqara regex match, but bad code - ATTR_QR_CODE: ( - "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" - "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024052" - ) - }, - # good consciot regex match, but bad code - {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024052"}, -) - - -@pytest.mark.parametrize("params", IC_FAIL_PARAMS) -async def test_permit_with_install_code_fail( - hass: HomeAssistant, app_controller, hass_admin_user: MockUser, params -) -> None: - """Test permit service with install code.""" - - with pytest.raises(vol.Invalid): - await hass.services.async_call( - DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) - ) - assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 0 - - -IC_QR_CODE_TEST_PARAMS = ( - ( - {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024051"}, - zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), - ( - {ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051"}, - zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), - ( - { - ATTR_QR_CODE: ( - "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" - "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024051" - ) - }, - zigpy.types.EUI64.convert("04:CF:8C:DF:3C:3C:3C:3C"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), -) - - -@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) -async def test_permit_with_qr_code( - hass: HomeAssistant, - app_controller, - hass_admin_user: MockUser, - params, - src_ieee, - code, -) -> None: - """Test permit service with install code from qr code.""" - - await hass.services.async_call( - DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) - ) - assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code - - -@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) -async def test_ws_permit_with_qr_code( - app_controller, zha_client, params, src_ieee, code -) -> None: - """Test permit service with install code from qr code.""" - - await zha_client.send_json( - {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - - assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code - - -@pytest.mark.parametrize("params", IC_FAIL_PARAMS) -async def test_ws_permit_with_install_code_fail( - app_controller, zha_client, params -) -> None: - """Test permit ws service with install code.""" - - await zha_client.send_json( - {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] is False - - assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 0 - - -@pytest.mark.parametrize( - ("params", "duration", "node"), - ( - ({}, 60, None), - ({ATTR_DURATION: 30}, 30, None), - ( - {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, - 33, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), - ), - ( - {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, - 60, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), - ), - ), -) -async def test_ws_permit_ha12( - app_controller, zha_client, params, duration, node -) -> None: - """Test permit ws service.""" - - await zha_client.send_json( - {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - - assert app_controller.permit.await_count == 1 - assert app_controller.permit.await_args[1]["time_s"] == duration - assert app_controller.permit.await_args[1]["node"] == node - assert app_controller.permit_with_key.call_count == 0 - - -async def test_get_network_settings(app_controller, zha_client) -> None: - """Test current network settings are returned.""" - - await app_controller.backups.create_backup() - - await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/settings"}) - msg = await zha_client.receive_json() - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert "radio_type" in msg["result"] - assert "network_info" in msg["result"]["settings"] - - -async def test_list_network_backups(app_controller, zha_client) -> None: - """Test backups are serialized.""" - - await app_controller.backups.create_backup() - - await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/list"}) - msg = await zha_client.receive_json() - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert "network_info" in msg["result"][0] - - -async def test_create_network_backup(app_controller, zha_client) -> None: - """Test creating backup.""" - - assert not app_controller.backups.backups - await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/create"}) - msg = await zha_client.receive_json() - assert len(app_controller.backups.backups) == 1 - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert "backup" in msg["result"] and "is_complete" in msg["result"] - - -async def test_restore_network_backup_success(app_controller, zha_client) -> None: - """Test successfully restoring a backup.""" - - backup = zigpy.backups.NetworkBackup() - - with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: - await zha_client.send_json( - { - ID: 6, - TYPE: f"{DOMAIN}/network/backups/restore", - "backup": backup.as_dict(), - } - ) - msg = await zha_client.receive_json() - - p.assert_called_once_with(backup) - assert "ezsp" not in backup.network_info.stack_specific - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - - -async def test_restore_network_backup_force_write_eui64( - app_controller, zha_client -) -> None: - """Test successfully restoring a backup.""" - - backup = zigpy.backups.NetworkBackup() - - with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: - await zha_client.send_json( - { - ID: 6, - TYPE: f"{DOMAIN}/network/backups/restore", - "backup": backup.as_dict(), - "ezsp_force_write_eui64": True, - } - ) - msg = await zha_client.receive_json() - - # EUI64 will be overwritten - p.assert_called_once_with( - backup.replace( - network_info=backup.network_info.replace( - stack_specific={"ezsp": {EZSP_OVERWRITE_EUI64: True}} - ) - ) - ) - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - - -@patch("zigpy.backups.NetworkBackup.from_dict", new=lambda v: v) -async def test_restore_network_backup_failure(app_controller, zha_client) -> None: - """Test successfully restoring a backup.""" - - with patch.object( - app_controller.backups, - "restore_backup", - new=AsyncMock(side_effect=ValueError("Restore failed")), - ) as p: - await zha_client.send_json( - {ID: 6, TYPE: f"{DOMAIN}/network/backups/restore", "backup": "a backup"} - ) - msg = await zha_client.receive_json() - - p.assert_called_once_with("a backup") - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_INVALID_FORMAT + radio_path = api.async_get_radio_path(hass) + assert radio_path == "/dev/ttyUSB0" diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index a92631f6da..23a76de4c2 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -120,7 +120,9 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None: ], ) @patch("homeassistant.components.zha.setup_quirks", Mock(return_value=True)) -@patch("homeassistant.components.zha.api.async_load_api", Mock(return_value=True)) +@patch( + "homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True) +) async def test_setup_with_v3_spaces_in_uri( hass: HomeAssistant, path: str, cleaned_path: str ) -> None: diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py new file mode 100644 index 0000000000..7a24daaa3b --- /dev/null +++ b/tests/components/zha/test_websocket_api.py @@ -0,0 +1,842 @@ +"""Test ZHA WebSocket API.""" +from binascii import unhexlify +from copy import deepcopy +from unittest.mock import AsyncMock, patch + +import pytest +import voluptuous as vol +import zigpy.backups +import zigpy.profiles.zha +import zigpy.types +import zigpy.zcl.clusters.general as general +import zigpy.zcl.clusters.security as security + +from homeassistant.components.websocket_api import const +from homeassistant.components.zha import DOMAIN +from homeassistant.components.zha.core.const import ( + ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, + ATTR_ENDPOINT_ID, + ATTR_ENDPOINT_NAMES, + ATTR_IEEE, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NEIGHBORS, + ATTR_QUIRK_APPLIED, + CLUSTER_TYPE_IN, + DATA_ZHA, + DATA_ZHA_GATEWAY, + EZSP_OVERWRITE_EUI64, + GROUP_ID, + GROUP_IDS, + GROUP_NAME, +) +from homeassistant.components.zha.websocket_api import ( + ATTR_DURATION, + ATTR_INSTALL_CODE, + ATTR_QR_CODE, + ATTR_SOURCE_IEEE, + ID, + SERVICE_PERMIT, + TYPE, + async_load_api, +) +from homeassistant.const import ATTR_NAME, Platform +from homeassistant.core import Context, HomeAssistant + +from .conftest import ( + FIXTURE_GRP_ID, + FIXTURE_GRP_NAME, + SIG_EP_INPUT, + SIG_EP_OUTPUT, + SIG_EP_PROFILE, + SIG_EP_TYPE, +) +from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS + +from tests.common import MockUser + +IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" +IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" + + +@pytest.fixture(autouse=True) +def required_platform_only(): + """Only set up the required and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.ALARM_CONTROL_PANEL, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ), + ): + yield + + +@pytest.fixture +async def device_switch(hass, zigpy_device_mock, zha_device_joined): + """Test ZHA switch platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.OnOff.cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + ieee=IEEE_SWITCH_DEVICE, + ) + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_ias_ace(hass, zigpy_device_mock, zha_device_joined): + """Test alarm control panel device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [security.IasAce.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + ) + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_groupable(hass, zigpy_device_mock, zha_device_joined): + """Test ZHA light platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.Basic.cluster_id, + general.Groups.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + ieee=IEEE_GROUPABLE_DEVICE, + ) + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def zha_client(hass, hass_ws_client, device_switch, device_groupable): + """Get ZHA WebSocket client.""" + + # load the ZHA API + async_load_api(hass) + return await hass_ws_client(hass) + + +async def test_device_clusters(hass: HomeAssistant, zha_client) -> None: + """Test getting device cluster info.""" + await zha_client.send_json( + {ID: 5, TYPE: "zha/devices/clusters", ATTR_IEEE: IEEE_SWITCH_DEVICE} + ) + + msg = await zha_client.receive_json() + + assert len(msg["result"]) == 2 + + cluster_infos = sorted(msg["result"], key=lambda k: k[ID]) + + cluster_info = cluster_infos[0] + assert cluster_info[TYPE] == CLUSTER_TYPE_IN + assert cluster_info[ID] == 0 + assert cluster_info[ATTR_NAME] == "Basic" + + cluster_info = cluster_infos[1] + assert cluster_info[TYPE] == CLUSTER_TYPE_IN + assert cluster_info[ID] == 6 + assert cluster_info[ATTR_NAME] == "OnOff" + + +async def test_device_cluster_attributes(zha_client) -> None: + """Test getting device cluster attributes.""" + await zha_client.send_json( + { + ID: 5, + TYPE: "zha/devices/clusters/attributes", + ATTR_ENDPOINT_ID: 1, + ATTR_IEEE: IEEE_SWITCH_DEVICE, + ATTR_CLUSTER_ID: 6, + ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, + } + ) + + msg = await zha_client.receive_json() + + attributes = msg["result"] + assert len(attributes) == 7 + + for attribute in attributes: + assert attribute[ID] is not None + assert attribute[ATTR_NAME] is not None + + +async def test_device_cluster_commands(zha_client) -> None: + """Test getting device cluster commands.""" + await zha_client.send_json( + { + ID: 5, + TYPE: "zha/devices/clusters/commands", + ATTR_ENDPOINT_ID: 1, + ATTR_IEEE: IEEE_SWITCH_DEVICE, + ATTR_CLUSTER_ID: 6, + ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, + } + ) + + msg = await zha_client.receive_json() + + commands = msg["result"] + assert len(commands) == 6 + + for command in commands: + assert command[ID] is not None + assert command[ATTR_NAME] is not None + assert command[TYPE] is not None + + +async def test_list_devices(zha_client) -> None: + """Test getting ZHA devices.""" + await zha_client.send_json({ID: 5, TYPE: "zha/devices"}) + + msg = await zha_client.receive_json() + + devices = msg["result"] + assert len(devices) == 2 + + msg_id = 100 + for device in devices: + msg_id += 1 + assert device[ATTR_IEEE] is not None + assert device[ATTR_MANUFACTURER] is not None + assert device[ATTR_MODEL] is not None + assert device[ATTR_NAME] is not None + assert device[ATTR_QUIRK_APPLIED] is not None + assert device["entities"] is not None + assert device[ATTR_NEIGHBORS] is not None + assert device[ATTR_ENDPOINT_NAMES] is not None + + for entity_reference in device["entities"]: + assert entity_reference[ATTR_NAME] is not None + assert entity_reference["entity_id"] is not None + + await zha_client.send_json( + {ID: msg_id, TYPE: "zha/device", ATTR_IEEE: device[ATTR_IEEE]} + ) + msg = await zha_client.receive_json() + device2 = msg["result"] + assert device == device2 + + +async def test_get_zha_config(zha_client) -> None: + """Test getting ZHA custom configuration.""" + await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) + + msg = await zha_client.receive_json() + + configuration = msg["result"] + assert configuration == BASE_CUSTOM_CONFIGURATION + + +async def test_get_zha_config_with_alarm( + hass: HomeAssistant, zha_client, device_ias_ace +) -> None: + """Test getting ZHA custom configuration.""" + await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) + + msg = await zha_client.receive_json() + + configuration = msg["result"] + assert configuration == CONFIG_WITH_ALARM_OPTIONS + + # test that the alarm options are not in the config when we remove the device + device_ias_ace.gateway.device_removed(device_ias_ace.device) + await hass.async_block_till_done() + await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) + + msg = await zha_client.receive_json() + + configuration = msg["result"] + assert configuration == BASE_CUSTOM_CONFIGURATION + + +async def test_update_zha_config(zha_client, zigpy_app_controller) -> None: + """Test updating ZHA custom configuration.""" + + configuration = deepcopy(CONFIG_WITH_ALARM_OPTIONS) + configuration["data"]["zha_options"]["default_light_transition"] = 10 + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ): + await zha_client.send_json( + {ID: 5, TYPE: "zha/configuration/update", "data": configuration["data"]} + ) + msg = await zha_client.receive_json() + assert msg["success"] + + await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) + msg = await zha_client.receive_json() + configuration = msg["result"] + assert configuration == configuration + + +async def test_device_not_found(zha_client) -> None: + """Test not found response from get device API.""" + await zha_client.send_json( + {ID: 6, TYPE: "zha/device", ATTR_IEEE: "28:6d:97:00:01:04:11:8c"} + ) + msg = await zha_client.receive_json() + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"]["code"] == const.ERR_NOT_FOUND + + +async def test_list_groups(zha_client) -> None: + """Test getting ZHA zigbee groups.""" + await zha_client.send_json({ID: 7, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 1 + + for group in groups: + assert group["group_id"] == FIXTURE_GRP_ID + assert group["name"] == FIXTURE_GRP_NAME + assert group["members"] == [] + + +async def test_get_group(zha_client) -> None: + """Test getting a specific ZHA zigbee group.""" + await zha_client.send_json({ID: 8, TYPE: "zha/group", GROUP_ID: FIXTURE_GRP_ID}) + + msg = await zha_client.receive_json() + assert msg["id"] == 8 + assert msg["type"] == const.TYPE_RESULT + + group = msg["result"] + assert group is not None + assert group["group_id"] == FIXTURE_GRP_ID + assert group["name"] == FIXTURE_GRP_NAME + assert group["members"] == [] + + +async def test_get_group_not_found(zha_client) -> None: + """Test not found response from get group API.""" + await zha_client.send_json({ID: 9, TYPE: "zha/group", GROUP_ID: 1_234_567}) + + msg = await zha_client.receive_json() + + assert msg["id"] == 9 + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"]["code"] == const.ERR_NOT_FOUND + + +async def test_list_groupable_devices(zha_client, device_groupable) -> None: + """Test getting ZHA devices that have a group cluster.""" + + await zha_client.send_json({ID: 10, TYPE: "zha/devices/groupable"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 10 + assert msg["type"] == const.TYPE_RESULT + + device_endpoints = msg["result"] + assert len(device_endpoints) == 1 + + for endpoint in device_endpoints: + assert endpoint["device"][ATTR_IEEE] == "01:2d:6f:00:0a:90:69:e8" + assert endpoint["device"][ATTR_MANUFACTURER] is not None + assert endpoint["device"][ATTR_MODEL] is not None + assert endpoint["device"][ATTR_NAME] is not None + assert endpoint["device"][ATTR_QUIRK_APPLIED] is not None + assert endpoint["device"]["entities"] is not None + assert endpoint["endpoint_id"] is not None + assert endpoint["entities"] is not None + + for entity_reference in endpoint["device"]["entities"]: + assert entity_reference[ATTR_NAME] is not None + assert entity_reference["entity_id"] is not None + + for entity_reference in endpoint["entities"]: + assert entity_reference["original_name"] is not None + + # Make sure there are no groupable devices when the device is unavailable + # Make device unavailable + device_groupable.available = False + + await zha_client.send_json({ID: 11, TYPE: "zha/devices/groupable"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 11 + assert msg["type"] == const.TYPE_RESULT + + device_endpoints = msg["result"] + assert len(device_endpoints) == 0 + + +async def test_add_group(zha_client) -> None: + """Test adding and getting a new ZHA zigbee group.""" + await zha_client.send_json({ID: 12, TYPE: "zha/group/add", GROUP_NAME: "new_group"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 12 + assert msg["type"] == const.TYPE_RESULT + + added_group = msg["result"] + + assert added_group["name"] == "new_group" + assert added_group["members"] == [] + + await zha_client.send_json({ID: 13, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 13 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 2 + + for group in groups: + assert group["name"] == FIXTURE_GRP_NAME or group["name"] == "new_group" + + +async def test_remove_group(zha_client) -> None: + """Test removing a new ZHA zigbee group.""" + + await zha_client.send_json({ID: 14, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 1 + + await zha_client.send_json( + {ID: 15, TYPE: "zha/group/remove", GROUP_IDS: [FIXTURE_GRP_ID]} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 15 + assert msg["type"] == const.TYPE_RESULT + + groups_remaining = msg["result"] + assert len(groups_remaining) == 0 + + await zha_client.send_json({ID: 16, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 16 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 0 + + +@pytest.fixture +async def app_controller(hass, setup_zha): + """Fixture for zigpy Application Controller.""" + await setup_zha() + controller = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].application_controller + p1 = patch.object(controller, "permit") + p2 = patch.object(controller, "permit_with_key", new=AsyncMock()) + with p1, p2: + yield controller + + +@pytest.mark.parametrize( + ("params", "duration", "node"), + ( + ({}, 60, None), + ({ATTR_DURATION: 30}, 30, None), + ( + {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, + 33, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), + ), + ( + {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, + 60, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), + ), + ), +) +async def test_permit_ha12( + hass: HomeAssistant, + app_controller, + hass_admin_user: MockUser, + params, + duration, + node, +) -> None: + """Test permit service.""" + + await hass.services.async_call( + DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) + ) + assert app_controller.permit.await_count == 1 + assert app_controller.permit.await_args[1]["time_s"] == duration + assert app_controller.permit.await_args[1]["node"] == node + assert app_controller.permit_with_key.call_count == 0 + + +IC_TEST_PARAMS = ( + ( + { + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", + }, + zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), + ( + { + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_INSTALL_CODE: "52797BF4A5084DAA8E1712B61741CA024051", + }, + zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), +) + + +@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_TEST_PARAMS) +async def test_permit_with_install_code( + hass: HomeAssistant, + app_controller, + hass_admin_user: MockUser, + params, + src_ieee, + code, +) -> None: + """Test permit service with install code.""" + + await hass.services.async_call( + DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) + ) + assert app_controller.permit.await_count == 0 + assert app_controller.permit_with_key.call_count == 1 + assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_key.await_args[1]["code"] == code + + +IC_FAIL_PARAMS = ( + { + # wrong install code + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4052", + }, + # incorrect service params + {ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051"}, + {ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE}, + { + # incorrect service params + ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", + ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", + }, + { + # incorrect service params + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", + }, + { + # good regex match, but bad code + ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024052" + }, + { + # good aqara regex match, but bad code + ATTR_QR_CODE: ( + "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" + "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024052" + ) + }, + # good consciot regex match, but bad code + {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024052"}, +) + + +@pytest.mark.parametrize("params", IC_FAIL_PARAMS) +async def test_permit_with_install_code_fail( + hass: HomeAssistant, app_controller, hass_admin_user: MockUser, params +) -> None: + """Test permit service with install code.""" + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) + ) + assert app_controller.permit.await_count == 0 + assert app_controller.permit_with_key.call_count == 0 + + +IC_QR_CODE_TEST_PARAMS = ( + ( + {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024051"}, + zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), + ( + {ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051"}, + zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), + ( + { + ATTR_QR_CODE: ( + "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" + "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024051" + ) + }, + zigpy.types.EUI64.convert("04:CF:8C:DF:3C:3C:3C:3C"), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), +) + + +@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) +async def test_permit_with_qr_code( + hass: HomeAssistant, + app_controller, + hass_admin_user: MockUser, + params, + src_ieee, + code, +) -> None: + """Test permit service with install code from qr code.""" + + await hass.services.async_call( + DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) + ) + assert app_controller.permit.await_count == 0 + assert app_controller.permit_with_key.call_count == 1 + assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_key.await_args[1]["code"] == code + + +@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) +async def test_ws_permit_with_qr_code( + app_controller, zha_client, params, src_ieee, code +) -> None: + """Test permit service with install code from qr code.""" + + await zha_client.send_json( + {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + assert app_controller.permit.await_count == 0 + assert app_controller.permit_with_key.call_count == 1 + assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_key.await_args[1]["code"] == code + + +@pytest.mark.parametrize("params", IC_FAIL_PARAMS) +async def test_ws_permit_with_install_code_fail( + app_controller, zha_client, params +) -> None: + """Test permit ws service with install code.""" + + await zha_client.send_json( + {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + + assert app_controller.permit.await_count == 0 + assert app_controller.permit_with_key.call_count == 0 + + +@pytest.mark.parametrize( + ("params", "duration", "node"), + ( + ({}, 60, None), + ({ATTR_DURATION: 30}, 30, None), + ( + {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, + 33, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), + ), + ( + {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, + 60, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), + ), + ), +) +async def test_ws_permit_ha12( + app_controller, zha_client, params, duration, node +) -> None: + """Test permit ws service.""" + + await zha_client.send_json( + {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + assert app_controller.permit.await_count == 1 + assert app_controller.permit.await_args[1]["time_s"] == duration + assert app_controller.permit.await_args[1]["node"] == node + assert app_controller.permit_with_key.call_count == 0 + + +async def test_get_network_settings(app_controller, zha_client) -> None: + """Test current network settings are returned.""" + + await app_controller.backups.create_backup() + + await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/settings"}) + msg = await zha_client.receive_json() + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert "radio_type" in msg["result"] + assert "network_info" in msg["result"]["settings"] + + +async def test_list_network_backups(app_controller, zha_client) -> None: + """Test backups are serialized.""" + + await app_controller.backups.create_backup() + + await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/list"}) + msg = await zha_client.receive_json() + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert "network_info" in msg["result"][0] + + +async def test_create_network_backup(app_controller, zha_client) -> None: + """Test creating backup.""" + + assert not app_controller.backups.backups + await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/create"}) + msg = await zha_client.receive_json() + assert len(app_controller.backups.backups) == 1 + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert "backup" in msg["result"] and "is_complete" in msg["result"] + + +async def test_restore_network_backup_success(app_controller, zha_client) -> None: + """Test successfully restoring a backup.""" + + backup = zigpy.backups.NetworkBackup() + + with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: + await zha_client.send_json( + { + ID: 6, + TYPE: f"{DOMAIN}/network/backups/restore", + "backup": backup.as_dict(), + } + ) + msg = await zha_client.receive_json() + + p.assert_called_once_with(backup) + assert "ezsp" not in backup.network_info.stack_specific + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + +async def test_restore_network_backup_force_write_eui64( + app_controller, zha_client +) -> None: + """Test successfully restoring a backup.""" + + backup = zigpy.backups.NetworkBackup() + + with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: + await zha_client.send_json( + { + ID: 6, + TYPE: f"{DOMAIN}/network/backups/restore", + "backup": backup.as_dict(), + "ezsp_force_write_eui64": True, + } + ) + msg = await zha_client.receive_json() + + # EUI64 will be overwritten + p.assert_called_once_with( + backup.replace( + network_info=backup.network_info.replace( + stack_specific={"ezsp": {EZSP_OVERWRITE_EUI64: True}} + ) + ) + ) + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + +@patch("zigpy.backups.NetworkBackup.from_dict", new=lambda v: v) +async def test_restore_network_backup_failure(app_controller, zha_client) -> None: + """Test successfully restoring a backup.""" + + with patch.object( + app_controller.backups, + "restore_backup", + new=AsyncMock(side_effect=ValueError("Restore failed")), + ) as p: + await zha_client.send_json( + {ID: 6, TYPE: f"{DOMAIN}/network/backups/restore", "backup": "a backup"} + ) + msg = await zha_client.receive_json() + + p.assert_called_once_with("a backup") + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"]["code"] == const.ERR_INVALID_FORMAT From cc4ff553471044fbf49f578b2694db87af300525 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Mar 2023 17:02:49 +0100 Subject: [PATCH 0036/1165] Update pvo to 1.0.0 (#90109) --- homeassistant/components/pvoutput/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index b8869cdee7..b78f49b74f 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["pvo==0.2.2"] + "requirements": ["pvo==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 91b480e973..36bea3aeba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1421,7 +1421,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==0.2.2 +pvo==1.0.0 # homeassistant.components.canary py-canary==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b88020aff..a93f569179 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1039,7 +1039,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==0.2.2 +pvo==1.0.0 # homeassistant.components.canary py-canary==0.5.3 From 94e247dc69a9b560ffd86b2f6cd922cc90f805d3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 18:52:52 +0100 Subject: [PATCH 0037/1165] Fix islamic_prayer_times setup (#90122) --- homeassistant/components/islamic_prayer_times/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 95a7db632b..d8810b0ad4 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.async_on_unload( config_entry.add_update_listener(async_options_updated) ) - hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True From 6db8867b81552343bc4e640fe64ec3e7a7c30a3d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Mar 2023 18:55:50 +0100 Subject: [PATCH 0038/1165] Update wled to 0.16.0 (#90120) --- homeassistant/components/wled/coordinator.py | 4 ++-- homeassistant/components/wled/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wled/snapshots/test_diagnostics.ambr | 1 + tests/components/wled/test_coordinator.py | 4 ++-- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 5afb5a6b44..9ba3fd2cb3 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -1,7 +1,7 @@ """DataUpdateCoordinator for WLED.""" from __future__ import annotations -from wled import WLED, Device as WLEDDevice, WLEDConnectionClosed, WLEDError +from wled import WLED, Device as WLEDDevice, WLEDConnectionClosedError, WLEDError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -68,7 +68,7 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): try: await self.wled.listen(callback=self.async_set_updated_data) - except WLEDConnectionClosed as err: + except WLEDConnectionClosedError as err: self.last_update_success = False self.logger.info(err) except WLEDError as err: diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 99309b9f00..b6d205912c 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["wled==0.15.0"], + "requirements": ["wled==0.16.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 36bea3aeba..42355c1073 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2638,7 +2638,7 @@ wirelesstagpy==0.8.1 withings-api==2.4.0 # homeassistant.components.wled -wled==0.15.0 +wled==0.16.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a93f569179..f9c4ae8876 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1878,7 +1878,7 @@ wiffi==1.1.2 withings-api==2.4.0 # homeassistant.components.wled -wled==0.15.0 +wled==0.16.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 diff --git a/tests/components/wled/snapshots/test_diagnostics.ambr b/tests/components/wled/snapshots/test_diagnostics.ambr index e06608033c..25db6a3116 100644 --- a/tests/components/wled/snapshots/test_diagnostics.ambr +++ b/tests/components/wled/snapshots/test_diagnostics.ambr @@ -92,6 +92,7 @@ 'effect_count': 81, 'filesystem': None, 'free_heap': 14600, + 'ip': 'Unknown', 'leds': dict({ '__type': "", 'repr': 'Leds(cct=False, count=30, fps=None, light_capabilities=None, max_power=850, max_segments=10, power=470, rgbw=False, wv=True, segment_light_capabilities=None)', diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 04d1c8f435..89817fb856 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock import pytest from wled import ( Device as WLEDDevice, - WLEDConnectionClosed, + WLEDConnectionClosedError, WLEDConnectionError, WLEDError, ) @@ -124,7 +124,7 @@ async def test_websocket( assert state.state == STATE_OFF # Resolve Future with a connection losed. - connection_finished.set_exception(WLEDConnectionClosed) + connection_finished.set_exception(WLEDConnectionClosedError) await hass.async_block_till_done() # Disconnect called, unsubbed Home Assistant stop listener From 3931e11fd92807f7d8ecbe110b2ff2df7fb73f36 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 20:10:10 +0100 Subject: [PATCH 0039/1165] Try to load integration before starting option flow (#90111) * Try to load integration before starting option flow * Adjust tests --- homeassistant/config_entries.py | 45 +++++++++++-------- .../components/config/test_config_entries.py | 4 ++ .../test_config_flow.py | 3 ++ .../helpers/test_schema_config_entry_flow.py | 14 +++++- tests/test_config_entries.py | 4 ++ 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 3ab16f6967..b21ae391e2 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -954,25 +954,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): Handler key is the domain of the component that we want to set up. """ - try: - integration = await loader.async_get_integration(self.hass, handler_key) - except loader.IntegrationNotFound as err: - _LOGGER.error("Cannot find integration %s", handler_key) - raise data_entry_flow.UnknownHandler from err - - # Make sure requirements and dependencies of component are resolved - await async_process_deps_reqs(self.hass, self._hass_config, integration) - - try: - integration.get_platform("config_flow") - except ImportError as err: - _LOGGER.error( - "Error occurred loading configuration flow for integration %s: %s", - handler_key, - err, - ) - raise data_entry_flow.UnknownHandler - + await _load_integration(self.hass, handler_key, self._hass_config) if (handler := HANDLERS.get(handler_key)) is None: raise data_entry_flow.UnknownHandler @@ -1842,6 +1824,8 @@ class OptionsFlowManager(data_entry_flow.FlowManager): if entry is None: raise UnknownEntry(handler_key) + await _load_integration(self.hass, entry.domain, {}) + if entry.domain not in HANDLERS: raise data_entry_flow.UnknownHandler @@ -2006,3 +1990,26 @@ async def support_remove_from_device(hass: HomeAssistant, domain: str) -> bool: integration = await loader.async_get_integration(hass, domain) component = integration.get_component() return hasattr(component, "async_remove_config_entry_device") + + +async def _load_integration( + hass: HomeAssistant, domain: str, hass_config: ConfigType +) -> None: + try: + integration = await loader.async_get_integration(hass, domain) + except loader.IntegrationNotFound as err: + _LOGGER.error("Cannot find integration %s", domain) + raise data_entry_flow.UnknownHandler from err + + # Make sure requirements and dependencies of component are resolved + await async_process_deps_reqs(hass, hass_config, integration) + + try: + integration.get_platform("config_flow") + except ImportError as err: + _LOGGER.error( + "Error occurred loading flow for integration %s: %s", + domain, + err, + ) + raise data_entry_flow.UnknownHandler diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index cf8df6aef6..f861d887b9 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -793,6 +793,8 @@ async def test_options_flow(hass: HomeAssistant, client) -> None: return OptionsFlowHandler() + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) MockConfigEntry( domain="test", entry_id="test1", @@ -824,6 +826,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) + mock_entity_platform(hass, "config_flow.test", None) class TestFlow(core_ce.ConfigFlow): @staticmethod @@ -889,6 +892,7 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) + mock_entity_platform(hass, "config_flow.test", None) class TestFlow(core_ce.ConfigFlow): @staticmethod diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 6ef3d13636..c74adbf32e 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.zha.core.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockModule, mock_integration @@ -159,6 +160,7 @@ async def test_option_flow_install_multi_pan_addon( start_addon, ) -> None: """Test installing the multi pan addon.""" + assert await async_setup_component(hass, "usb", {}) mock_integration(hass, MockModule("hassio")) # Setup the config entry @@ -253,6 +255,7 @@ async def test_option_flow_install_multi_pan_addon_zha( start_addon, ) -> None: """Test installing the multi pan addon when a zha config entry exists.""" + assert await async_setup_component(hass, "usb", {}) mock_integration(hass, MockModule("hassio")) # Setup the config entry diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 9919a53839..0bc8e0f1ff 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -23,7 +23,13 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from homeassistant.util.decorator import Registry -from tests.common import MockConfigEntry, mock_platform +from tests.common import ( + MockConfigEntry, + MockModule, + mock_entity_platform, + mock_integration, + mock_platform, +) TEST_DOMAIN = "test" @@ -226,6 +232,8 @@ async def test_options_flow_advanced_option( config_flow = {} options_flow = OPTIONS_FLOW + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -513,6 +521,8 @@ async def test_suggested_values( config_flow = {} options_flow = OPTIONS_FLOW + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -624,6 +634,8 @@ async def test_options_flow_state(hass: HomeAssistant) -> None: config_flow = {} options_flow = OPTIONS_FLOW + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) config_entry = MockConfigEntry( data={}, domain="test", diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 29041730da..c8cdc56198 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1101,6 +1101,8 @@ async def test_entry_options( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can set options on an entry.""" + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1137,6 +1139,8 @@ async def test_entry_options_abort( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can abort options flow.""" + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) From 4c98495fe067acd7c51546c5a923417c1c938fa3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 09:19:43 -1000 Subject: [PATCH 0040/1165] Bump ulid-transform to 0.5.1 (#90123) changelog: https://github.com/bdraco/ulid-transform/compare/v0.4.2...v0.5.1 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index edc88caef8..ffef59913e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ requests==2.28.2 scapy==2.5.0 sqlalchemy==2.0.6 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.4.2 +ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 diff --git a/pyproject.toml b/pyproject.toml index 3ee9bc7be5..d8ba8e7475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "pyyaml==6.0", "requests==2.28.2", "typing-extensions>=4.5.0,<5.0", - "ulid-transform==0.4.2", + "ulid-transform==0.5.1", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.8.1", diff --git a/requirements.txt b/requirements.txt index 168488a54d..1b4874e2c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ python-slugify==4.0.1 pyyaml==6.0 requests==2.28.2 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.4.2 +ulid-transform==0.5.1 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.8.1 From 1ea3312ed4d7dd53b8f878292a423cf305b2a020 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 20:20:42 +0100 Subject: [PATCH 0041/1165] Deduplicate multiprotocol addon helper (#90102) * Deduplicate multiprotocol addon helper * Clarify --- .../silabs_multiprotocol_addon.py | 51 +++++++++++++ .../homeassistant_sky_connect/__init__.py | 71 +++---------------- .../homeassistant_yellow/__init__.py | 55 +++----------- .../homeassistant_sky_connect/test_init.py | 8 +-- 4 files changed, 75 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 20fdb97e38..bba6b447c7 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -26,6 +26,7 @@ from homeassistant.data_entry_flow import ( FlowManager, FlowResult, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG @@ -356,3 +357,53 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): if user_input is None: return self.async_show_form(step_id="addon_installed_other_device") return self.async_create_entry(title="", data={}) + + +async def check_multi_pan_addon(hass: HomeAssistant) -> None: + """Check the multi-PAN addon state, and start it if installed but not started. + + Does nothing if Hass.io is not loaded. + Raises on error or if the add-on is installed but not started. + """ + if not is_hassio(hass): + return + + addon_manager: AddonManager = get_addon_manager(hass) + try: + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + except AddonError as err: + _LOGGER.error(err) + raise HomeAssistantError from err + + # Request the addon to start if it's not started + # addon_manager.async_start_addon returns as soon as the start request has been sent + # and does not wait for the addon to be started, so we raise below + if addon_info.state == AddonState.NOT_RUNNING: + await addon_manager.async_start_addon() + + if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): + _LOGGER.debug("Multi pan addon installed and in state %s", addon_info.state) + raise HomeAssistantError + + +async def get_multi_pan_addon_info( + hass: HomeAssistant, device_path: str +) -> AddonInfo | None: + """Return AddonInfo if the multi-PAN addon is using the given device. + + Returns None if Hass.io is not loaded, the addon is not running or the addon is + connected to another device. + """ + if not is_hassio(hass): + return None + + addon_manager: AddonManager = get_addon_manager(hass) + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + + if addon_info.state != AddonState.RUNNING: + return None + + if addon_info.options["device"] != device_path: + return None + + return addon_info diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 1de919b8c7..54c11fd379 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -1,75 +1,19 @@ """The Home Assistant SkyConnect integration.""" from __future__ import annotations -import logging - from homeassistant.components import usb -from homeassistant.components.hassio import ( - AddonError, - AddonInfo, - AddonManager, - AddonState, - is_hassio, -) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( - get_addon_manager, + check_multi_pan_addon, + get_multi_pan_addon_info, get_zigbee_socket, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from .const import DOMAIN from .util import get_usb_service_info -_LOGGER = logging.getLogger(__name__) - - -async def _wait_multi_pan_addon(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Wait for multi-PAN info to be available.""" - if not is_hassio(hass): - return - - addon_manager: AddonManager = get_addon_manager(hass) - try: - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - except AddonError as err: - _LOGGER.error(err) - raise ConfigEntryNotReady from err - - # Start the addon if it's not started - if addon_info.state == AddonState.NOT_RUNNING: - await addon_manager.async_start_addon() - - if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): - _LOGGER.debug( - "Multi pan addon in state %s, delaying yellow config entry setup", - addon_info.state, - ) - raise ConfigEntryNotReady - - -async def _multi_pan_addon_info( - hass: HomeAssistant, entry: ConfigEntry -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect.""" - if not is_hassio(hass): - return None - - addon_manager: AddonManager = get_addon_manager(hass) - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - - if addon_info.state != AddonState.RUNNING: - return None - - usb_dev = entry.data["device"] - dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) - - if addon_info.options["device"] != dev_path: - return None - - return addon_info - async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: """Finish Home Assistant SkyConnect config entry setup.""" @@ -87,7 +31,9 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return - addon_info = await _multi_pan_addon_info(hass, entry) + usb_dev = entry.data["device"] + dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) + addon_info = await get_multi_pan_addon_info(hass, dev_path) if not addon_info: usb_info = get_usb_service_info(entry) @@ -115,7 +61,10 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant SkyConnect config entry.""" - await _wait_multi_pan_addon(hass, entry) + try: + await check_multi_pan_addon(hass) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err @callback def async_usb_scan_done() -> None: diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 9e22736fc7..72df6a5707 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -1,58 +1,18 @@ """The Home Assistant Yellow integration.""" from __future__ import annotations -import logging - -from homeassistant.components.hassio import ( - AddonError, - AddonInfo, - AddonManager, - AddonState, - get_os_info, -) +from homeassistant.components.hassio import get_os_info from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( - get_addon_manager, + check_multi_pan_addon, + get_multi_pan_addon_info, get_zigbee_socket, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from .const import RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA -_LOGGER = logging.getLogger(__name__) - - -async def _multi_pan_addon_info( - hass: HomeAssistant, entry: ConfigEntry -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is enabled for the Yellow's radio.""" - addon_manager: AddonManager = get_addon_manager(hass) - try: - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - except AddonError as err: - _LOGGER.error(err) - raise ConfigEntryNotReady from err - - # Start the addon if it's not started - if addon_info.state == AddonState.NOT_RUNNING: - await addon_manager.async_start_addon() - - if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): - _LOGGER.debug( - "Multi pan addon in state %s, delaying yellow config entry setup", - addon_info.state, - ) - raise ConfigEntryNotReady - - if addon_info.state == AddonState.NOT_INSTALLED: - return None - - if addon_info.options["device"] != RADIO_DEVICE: - return None - - return addon_info - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant Yellow config entry.""" @@ -66,7 +26,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False - addon_info = await _multi_pan_addon_info(hass, entry) + try: + await check_multi_pan_addon(hass) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err + + addon_info = await get_multi_pan_addon_info(hass, RADIO_DEVICE) if not addon_info: hw_discovery_data = ZHA_HW_DISCOVERY_DATA diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 3794b91a9e..746e119082 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -172,7 +172,7 @@ async def test_setup_zha_multipan( ) as mock_is_plugged_in, patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -226,7 +226,7 @@ async def test_setup_zha_multipan_other_device( ) as mock_is_plugged_in, patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -304,7 +304,7 @@ async def test_setup_entry_addon_info_fails( ), patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) @@ -333,7 +333,7 @@ async def test_setup_entry_addon_not_running( ), patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) From 5948347b6bd3030ffc2d33d3ac2679d3a655bc95 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 20:24:05 +0100 Subject: [PATCH 0042/1165] Fix switch_as_x entity naming (#89992) * Fix switch_as_x entity naming * Simplify name logic --- homeassistant/components/switch_as_x/cover.py | 6 +- .../components/switch_as_x/entity.py | 36 ++++++--- homeassistant/components/switch_as_x/fan.py | 6 +- homeassistant/components/switch_as_x/light.py | 6 +- homeassistant/components/switch_as_x/lock.py | 6 +- homeassistant/components/switch_as_x/siren.py | 6 +- tests/components/switch_as_x/test_cover.py | 23 +++--- tests/components/switch_as_x/test_fan.py | 23 +++--- tests/components/switch_as_x/test_init.py | 74 +++++++++++++++++-- tests/components/switch_as_x/test_light.py | 4 +- tests/components/switch_as_x/test_lock.py | 19 ++--- tests/components/switch_as_x/test_siren.py | 23 +++--- 12 files changed, 148 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/switch_as_x/cover.py b/homeassistant/components/switch_as_x/cover.py index 9d7a7bf617..b7f8e5bf97 100644 --- a/homeassistant/components/switch_as_x/cover.py +++ b/homeassistant/components/switch_as_x/cover.py @@ -30,18 +30,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ CoverSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index ac56b4c607..8432c46f85 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -10,32 +10,47 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, STATE_UNAVAILABLE, - EntityCategory, ) -from homeassistant.core import Event, callback -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import Entity, ToggleEntity +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity from homeassistant.helpers.event import async_track_state_change_event from .const import DOMAIN as SWITCH_AS_X_DOMAIN class BaseEntity(Entity): - """Represents a Switch as a X.""" + """Represents a Switch as an X.""" _attr_should_poll = False def __init__( self, - name: str, + hass: HomeAssistant, + config_entry_title: str, switch_entity_id: str, unique_id: str | None, - device_id: str | None, - entity_category: EntityCategory | None, ) -> None: - """Initialize Light Switch.""" + """Initialize Switch as an X.""" + registry = er.async_get(hass) + device_registry = dr.async_get(hass) + wrapped_switch = registry.async_get(switch_entity_id) + device_id = wrapped_switch.device_id if wrapped_switch else None + entity_category = wrapped_switch.entity_category if wrapped_switch else None + has_entity_name = wrapped_switch.has_entity_name if wrapped_switch else False + + name: str | None = config_entry_title + if wrapped_switch: + name = wrapped_switch.name or wrapped_switch.original_name + self._device_id = device_id + if device_id and (device := device_registry.async_get(device_id)): + self._attr_device_info = DeviceInfo( + connections=device.connections, + identifiers=device.identifiers, + ) self._attr_entity_category = entity_category + self._attr_has_entity_name = has_entity_name self._attr_name = name self._attr_unique_id = unique_id self._switch_entity_id = switch_entity_id @@ -69,10 +84,9 @@ class BaseEntity(Entity): # Call once on adding _async_state_changed_listener() - # Add this entity to the wrapped switch's device + # Update entity options registry = er.async_get(self.hass) if registry.async_get(self.entity_id) is not None: - registry.async_update_entity(self.entity_id, device_id=self._device_id) registry.async_update_entity_options( self.entity_id, SWITCH_AS_X_DOMAIN, diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index bfc4d2e037..87a6c38729 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -23,18 +23,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ FanSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/light.py b/homeassistant/components/switch_as_x/light.py index c8181bf35f..7bcdb659e9 100644 --- a/homeassistant/components/switch_as_x/light.py +++ b/homeassistant/components/switch_as_x/light.py @@ -21,18 +21,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ LightSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/lock.py b/homeassistant/components/switch_as_x/lock.py index a0aac15a70..e3c29a1cf4 100644 --- a/homeassistant/components/switch_as_x/lock.py +++ b/homeassistant/components/switch_as_x/lock.py @@ -30,18 +30,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ LockSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/siren.py b/homeassistant/components/switch_as_x/siren.py index 635aa4e2d7..88ff9a322d 100644 --- a/homeassistant/components/switch_as_x/siren.py +++ b/homeassistant/components/switch_as_x/siren.py @@ -21,18 +21,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ SirenSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/tests/components/switch_as_x/test_cover.py b/tests/components/switch_as_x/test_cover.py index d8317a51b8..d0aef0b949 100644 --- a/tests/components/switch_as_x/test_cover.py +++ b/tests/components/switch_as_x/test_cover.py @@ -45,6 +45,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(hass: HomeAssistant) -> None: """Test service calls to cover.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -52,43 +53,43 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.COVER, }, - title="garage_door", + title="Title is ignored", ) 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.states.get("cover.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN await hass.services.async_call( COVER_DOMAIN, SERVICE_TOGGLE, - {CONF_ENTITY_ID: "cover.garage_door"}, + {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("cover.garage_door").state == STATE_CLOSED + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, - {CONF_ENTITY_ID: "cover.garage_door"}, + {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("cover.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, - {CONF_ENTITY_ID: "cover.garage_door"}, + {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("cover.garage_door").state == STATE_CLOSED + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED await hass.services.async_call( SWITCH_DOMAIN, @@ -98,7 +99,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("cover.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN await hass.services.async_call( SWITCH_DOMAIN, @@ -108,7 +109,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("cover.garage_door").state == STATE_CLOSED + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED await hass.services.async_call( SWITCH_DOMAIN, @@ -118,4 +119,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("cover.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN diff --git a/tests/components/switch_as_x/test_fan.py b/tests/components/switch_as_x/test_fan.py index b7b746344b..cf6789d439 100644 --- a/tests/components/switch_as_x/test_fan.py +++ b/tests/components/switch_as_x/test_fan.py @@ -41,6 +41,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(hass: HomeAssistant) -> None: """Test service calls affecting the switch as fan entity.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -48,43 +49,43 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.FAN, }, - title="wind_machine", + title="Title is ignored", ) 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.states.get("fan.wind_machine").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TOGGLE, - {CONF_ENTITY_ID: "fan.wind_machine"}, + {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("fan.wind_machine").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {CONF_ENTITY_ID: "fan.wind_machine"}, + {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("fan.wind_machine").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, - {CONF_ENTITY_ID: "fan.wind_machine"}, + {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("fan.wind_machine").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -94,7 +95,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("fan.wind_machine").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, @@ -104,7 +105,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("fan.wind_machine").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -114,4 +115,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("fan.wind_machine").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index a95725999d..2d63ce9617 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -71,7 +71,9 @@ async def test_entity_registry_events( ) -> None: """Test entity registry events are tracked.""" registry = er.async_get(hass) - registry_entry = registry.async_get_or_create("switch", "test", "unique") + registry_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) switch_entity_id = registry_entry.entity_id hass.states.async_set(switch_entity_id, STATE_ON) @@ -144,6 +146,7 @@ async def test_device_registry_config_entry_1( "unique", config_entry=switch_config_entry, device_id=device_entry.id, + original_name="ABC", ) # Add another config entry to the same device device_registry.async_update_device( @@ -202,6 +205,7 @@ async def test_device_registry_config_entry_2( "unique", config_entry=switch_config_entry, device_id=device_entry.id, + original_name="ABC", ) switch_as_x_config_entry = MockConfigEntry( @@ -272,7 +276,9 @@ async def test_config_entry_entity_id( async def test_config_entry_uuid(hass: HomeAssistant, target_domain: Platform) -> None: """Test light switch setup from config entry with entity registry id.""" registry = er.async_get(hass) - registry_entry = registry.async_get_or_create("switch", "test", "unique") + registry_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) config_entry = MockConfigEntry( data={}, @@ -305,7 +311,7 @@ async def test_device(hass: HomeAssistant, target_domain: Platform) -> None: connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) switch_entity_entry = entity_registry.async_get_or_create( - "switch", "test", "unique", device_id=device_entry.id + "switch", "test", "unique", device_id=device_entry.id, original_name="ABC" ) switch_as_x_config_entry = MockConfigEntry( @@ -414,7 +420,9 @@ async def test_entity_category_inheritance( """Test the entity category is inherited from source device.""" registry = er.async_get(hass) - switch_entity_entry = registry.async_get_or_create("switch", "test", "unique") + switch_entity_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) registry.async_update_entity( switch_entity_entry.entity_id, entity_category=EntityCategory.CONFIG ) @@ -448,7 +456,9 @@ async def test_entity_options( """Test the source entity is stored as an entity option.""" registry = er.async_get(hass) - switch_entity_entry = registry.async_get_or_create("switch", "test", "unique") + switch_entity_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) registry.async_update_entity( switch_entity_entry.entity_id, entity_category=EntityCategory.CONFIG ) @@ -474,3 +484,57 @@ async def test_entity_options( assert entity_entry.options == { DOMAIN: {"entity_id": switch_entity_entry.entity_id} } + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_entity_name( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test the source entity has entity_name set to True.""" + registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + switch_config_entry = MockConfigEntry() + + device_entry = device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="Device name", + ) + + switch_entity_entry = registry.async_get_or_create( + "switch", + "test", + "unique", + device_id=device_entry.id, + has_entity_name=True, + ) + switch_entity_entry = registry.async_update_entity( + switch_entity_entry.entity_id, + config_entry_id=switch_config_entry.entry_id, + ) + + # Add the config entry + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_entry.id, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + ) + switch_as_x_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entry = registry.async_get(f"{target_domain}.device_name") + assert entity_entry + assert entity_entry.device_id == switch_entity_entry.device_id + assert entity_entry.has_entity_name is True + assert entity_entry.original_name is None + assert entity_entry.options == { + DOMAIN: {"entity_id": switch_entity_entry.entity_id} + } diff --git a/tests/components/switch_as_x/test_light.py b/tests/components/switch_as_x/test_light.py index b5976f1784..9a33bab20a 100644 --- a/tests/components/switch_as_x/test_light.py +++ b/tests/components/switch_as_x/test_light.py @@ -58,6 +58,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_light_service_calls(hass: HomeAssistant) -> None: """Test service calls to light.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -111,6 +112,7 @@ async def test_light_service_calls(hass: HomeAssistant) -> None: async def test_switch_service_calls(hass: HomeAssistant) -> None: """Test service calls to switch.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -118,7 +120,7 @@ async def test_switch_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.LIGHT, }, - title="decorative_lights", + title="Title is ignored", ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/switch_as_x/test_lock.py b/tests/components/switch_as_x/test_lock.py index de4c729e49..6d30ac4646 100644 --- a/tests/components/switch_as_x/test_lock.py +++ b/tests/components/switch_as_x/test_lock.py @@ -44,6 +44,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(hass: HomeAssistant) -> None: """Test service calls affecting the switch as lock entity.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -51,33 +52,33 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.LOCK, }, - title="candy_jar", + title="Title is ignored", ) 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.states.get("lock.candy_jar").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, - {CONF_ENTITY_ID: "lock.candy_jar"}, + {CONF_ENTITY_ID: "lock.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.candy_jar").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, - {CONF_ENTITY_ID: "lock.candy_jar"}, + {CONF_ENTITY_ID: "lock.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("lock.candy_jar").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -87,7 +88,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.candy_jar").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -97,7 +98,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("lock.candy_jar").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -107,4 +108,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.candy_jar").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED diff --git a/tests/components/switch_as_x/test_siren.py b/tests/components/switch_as_x/test_siren.py index 2b3dedf6fb..f776ab2ae0 100644 --- a/tests/components/switch_as_x/test_siren.py +++ b/tests/components/switch_as_x/test_siren.py @@ -41,6 +41,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(hass: HomeAssistant) -> None: """Test service calls affecting the switch as siren entity.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -48,43 +49,43 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.SIREN, }, - title="noise_maker", + title="Title is ignored", ) 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.states.get("siren.noise_maker").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON await hass.services.async_call( SIREN_DOMAIN, SERVICE_TOGGLE, - {CONF_ENTITY_ID: "siren.noise_maker"}, + {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("siren.noise_maker").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF await hass.services.async_call( SIREN_DOMAIN, SERVICE_TURN_ON, - {CONF_ENTITY_ID: "siren.noise_maker"}, + {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("siren.noise_maker").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON await hass.services.async_call( SIREN_DOMAIN, SERVICE_TURN_OFF, - {CONF_ENTITY_ID: "siren.noise_maker"}, + {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("siren.noise_maker").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -94,7 +95,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("siren.noise_maker").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, @@ -104,7 +105,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("siren.noise_maker").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -114,4 +115,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("siren.noise_maker").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON From 4ebce9746db2da6f0f863d35126f4d67c7e5d4f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 10:05:23 -1000 Subject: [PATCH 0043/1165] Add schema auto repairs for states tables (#90083) --- .../recorder/auto_repairs/schema.py | 218 ++++++++++++++ .../recorder/auto_repairs/states/schema.py | 39 +++ .../auto_repairs/statistics/schema.py | 280 +----------------- .../components/recorder/db_schema.py | 17 +- .../components/recorder/migration.py | 86 ++++-- pylint/plugins/hass_enforce_type_hints.py | 2 +- .../recorder/auto_repairs/states/__init__.py | 5 + .../auto_repairs/states/test_schema.py | 106 +++++++ .../auto_repairs/statistics/test_schema.py | 178 +---------- .../recorder/auto_repairs/test_schema.py | 253 ++++++++++++++++ tests/conftest.py | 32 +- 11 files changed, 731 insertions(+), 485 deletions(-) create mode 100644 homeassistant/components/recorder/auto_repairs/schema.py create mode 100644 homeassistant/components/recorder/auto_repairs/states/schema.py create mode 100644 tests/components/recorder/auto_repairs/states/__init__.py create mode 100644 tests/components/recorder/auto_repairs/states/test_schema.py create mode 100644 tests/components/recorder/auto_repairs/test_schema.py diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py new file mode 100644 index 0000000000..ec05eafd14 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -0,0 +1,218 @@ +"""Schema repairs.""" +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import logging +from typing import TYPE_CHECKING + +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm.attributes import InstrumentedAttribute + +from ..const import SupportedDialect +from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE +from ..util import session_scope + +if TYPE_CHECKING: + from .. import Recorder + +_LOGGER = logging.getLogger(__name__) + +MYSQL_ERR_INCORRECT_STRING_VALUE = 1366 + +# This name can't be represented unless 4-byte UTF-8 unicode is supported +UTF8_NAME = "𓆚𓃗" + +# This number can't be accurately represented as a 32-bit float +PRECISE_NUMBER = 1.000000000000001 + + +def _get_precision_column_types( + table_object: type[DeclarativeBase], +) -> list[str]: + """Get the column names for the columns that need to be checked for precision.""" + return [ + column.key + for column in table_object.__table__.columns + if column.type is DOUBLE_TYPE + ] + + +def validate_table_schema_supports_utf8( + instance: Recorder, + table_object: type[DeclarativeBase], + columns: tuple[InstrumentedAttribute, ...], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Lack of full utf8 support is only an issue for MySQL / MariaDB + if instance.dialect_name != SupportedDialect.MYSQL: + return schema_errors + + try: + schema_errors = _validate_table_schema_supports_utf8( + instance, table_object, columns + ) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_table_schema_supports_utf8( + instance: Recorder, + table_object: type[DeclarativeBase], + columns: tuple[InstrumentedAttribute, ...], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + db_object = table_object(**{column.key: UTF8_NAME for column in columns}) + table = table_object.__tablename__ + # Try inserting some data which needs utf8mb4 support + session.add(db_object) + try: + session.flush() + except OperationalError as err: + if err.orig and err.orig.args[0] == MYSQL_ERR_INCORRECT_STRING_VALUE: + _LOGGER.debug( + "Database %s statistics_meta does not support 4-byte UTF-8", + table, + ) + schema_errors.add(f"{table}.4-byte UTF-8") + return schema_errors + raise + finally: + session.rollback() + return schema_errors + + +def validate_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL + if instance.dialect_name not in ( + SupportedDialect.MYSQL, + SupportedDialect.POSTGRESQL, + ): + return schema_errors + try: + schema_errors = _validate_db_schema_precision(instance, table_object) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + columns = _get_precision_column_types(table_object) + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + db_object = table_object(**{column: PRECISE_NUMBER for column in columns}) + table = table_object.__tablename__ + try: + session.add(db_object) + session.flush() + session.refresh(db_object) + _check_columns( + schema_errors=schema_errors, + stored={column: getattr(db_object, column) for column in columns}, + expected={column: PRECISE_NUMBER for column in columns}, + columns=columns, + table_name=table, + supports="double precision", + ) + finally: + session.rollback() + return schema_errors + + +def _log_schema_errors( + table_object: type[DeclarativeBase], schema_errors: set[str] +) -> None: + """Log schema errors.""" + if not schema_errors: + return + _LOGGER.debug( + "Detected %s schema errors: %s", + table_object.__tablename__, + ", ".join(sorted(schema_errors)), + ) + + +def _check_columns( + schema_errors: set[str], + stored: Mapping, + expected: Mapping, + columns: Iterable[str], + table_name: str, + supports: str, +) -> None: + """Check that the columns in the table support the given feature. + + Errors are logged and added to the schema_errors set. + """ + for column in columns: + if stored[column] == expected[column]: + continue + schema_errors.add(f"{table_name}.{supports}") + _LOGGER.error( + "Column %s in database table %s does not support %s (stored=%s != expected=%s)", + column, + table_name, + supports, + stored[column], + expected[column], + ) + + +def correct_db_schema_utf8( + instance: Recorder, table_object: type[DeclarativeBase], schema_errors: set[str] +) -> None: + """Correct utf8 issues detected by validate_db_schema.""" + table_name = table_object.__tablename__ + if f"{table_name}.4-byte UTF-8" in schema_errors: + from ..migration import ( # pylint: disable=import-outside-toplevel + _correct_table_character_set_and_collation, + ) + + _correct_table_character_set_and_collation(table_name, instance.get_session) + + +def correct_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], + schema_errors: set[str], +) -> None: + """Correct precision issues detected by validate_db_schema.""" + table_name = table_object.__tablename__ + + if f"{table_name}.double precision" in schema_errors: + from ..migration import ( # pylint: disable=import-outside-toplevel + _modify_columns, + ) + + precision_columns = _get_precision_column_types(table_object) + # Attempt to convert timestamp columns to µs precision + session_maker = instance.get_session + engine = instance.engine + assert engine is not None, "Engine should be set" + _modify_columns( + session_maker, + engine, + table_name, + [f"{column} {DOUBLE_PRECISION_TYPE_SQL}" for column in precision_columns], + ) diff --git a/homeassistant/components/recorder/auto_repairs/states/schema.py b/homeassistant/components/recorder/auto_repairs/states/schema.py new file mode 100644 index 0000000000..258e15cbb5 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/states/schema.py @@ -0,0 +1,39 @@ +"""States schema repairs.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...db_schema import StateAttributes, States +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) + +if TYPE_CHECKING: + from ... import Recorder + +TABLE_UTF8_COLUMNS = { + States: (States.state,), + StateAttributes: (StateAttributes.shared_attrs,), +} + + +def validate_db_schema(instance: Recorder) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + for table, columns in TABLE_UTF8_COLUMNS.items(): + schema_errors |= validate_table_schema_supports_utf8(instance, table, columns) + schema_errors |= validate_db_schema_precision(instance, States) + return schema_errors + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + for table in (States, StateAttributes): + correct_db_schema_utf8(instance, table, schema_errors) + correct_db_schema_precision(instance, States, schema_errors) diff --git a/homeassistant/components/recorder/auto_repairs/statistics/schema.py b/homeassistant/components/recorder/auto_repairs/statistics/schema.py index bbf59080ac..9b4687cb72 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/schema.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/schema.py @@ -1,28 +1,16 @@ """Statistics schema repairs.""" from __future__ import annotations -from collections.abc import Callable, Mapping -import contextlib -from datetime import datetime import logging from typing import TYPE_CHECKING -from sqlalchemy import text -from sqlalchemy.engine import Engine -from sqlalchemy.exc import OperationalError, SQLAlchemyError -from sqlalchemy.orm.session import Session - -from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util - -from ...const import DOMAIN, SupportedDialect -from ...db_schema import Statistics, StatisticsShortTerm -from ...models import StatisticData, StatisticMetaData, datetime_to_timestamp_or_none -from ...statistics import ( - _import_statistics_with_session, - _statistics_during_period_with_session, +from ...db_schema import Statistics, StatisticsMeta, StatisticsShortTerm +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, ) -from ...util import session_scope if TYPE_CHECKING: from ... import Recorder @@ -30,200 +18,14 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -def _validate_db_schema_utf8( - instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: +def validate_db_schema(instance: Recorder) -> set[str]: """Do some basic checks for common schema errors caused by manual migration.""" schema_errors: set[str] = set() - - # Lack of full utf8 support is only an issue for MySQL / MariaDB - if instance.dialect_name != SupportedDialect.MYSQL: - return schema_errors - - # This name can't be represented unless 4-byte UTF-8 unicode is supported - utf8_name = "𓆚𓃗" - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": utf8_name, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics_meta_manager = instance.statistics_meta_manager - - # Try inserting some metadata which needs utf8mb4 support - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - old_metadata_dict = statistics_meta_manager.get_many( - session, statistic_ids={statistic_id} - ) - try: - statistics_meta_manager.update_or_add( - session, metadata, old_metadata_dict - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except OperationalError as err: - if err.orig and err.orig.args[0] == 1366: - _LOGGER.debug( - "Database table statistics_meta does not support 4-byte UTF-8" - ) - schema_errors.add("statistics_meta.4-byte UTF-8") - session.rollback() - else: - raise - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - return schema_errors - - -def _get_future_year() -> int: - """Get a year in the future.""" - return datetime.now().year + 1 - - -def _validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - statistics_meta_manager = instance.statistics_meta_manager - - # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL - if instance.dialect_name not in ( - SupportedDialect.MYSQL, - SupportedDialect.POSTGRESQL, - ): - return schema_errors - - # This number can't be accurately represented as a 32-bit float - precise_number = 1.000000000000001 - # This time can't be accurately represented unless datetimes have µs precision - # - # We want to insert statistics for a time in the future, in case they - # have conflicting metadata_id's with existing statistics that were - # never cleaned up. By inserting in the future, we can be sure that - # that by selecting the last inserted row, we will get the one we - # just inserted. - # - future_year = _get_future_year() - precise_time = datetime(future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - start_time = datetime(future_year, 10, 6, tzinfo=dt_util.UTC) - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": None, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics: StatisticData = { - "last_reset": precise_time, - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time, - "state": precise_number, - "sum": precise_number, - } - - def check_columns( - schema_errors: set[str], - stored: Mapping, - expected: Mapping, - columns: tuple[str, ...], - table_name: str, - supports: str, - ) -> None: - for column in columns: - if stored[column] != expected[column]: - schema_errors.add(f"{table_name}.{supports}") - _LOGGER.error( - "Column %s in database table %s does not support %s (stored=%s != expected=%s)", - column, - table_name, - supports, - stored[column], - expected[column], - ) - - # Insert / adjust a test statistics row in each of the tables - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, + schema_errors |= validate_table_schema_supports_utf8( + instance, StatisticsMeta, (StatisticsMeta.statistic_id,) ) - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - for table in tables: - _import_statistics_with_session( - instance, session, metadata, (statistics,), table - ) - stored_statistics = _statistics_during_period_with_session( - hass, - session, - start_time, - None, - {statistic_id}, - "hour" if table == Statistics else "5minute", - None, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - if not (stored_statistic := stored_statistics.get(statistic_id)): - _LOGGER.warning( - "Schema validation failed for table: %s", table.__tablename__ - ) - continue - - # We want to look at the last inserted row to make sure there - # is not previous garbage data in the table that would cause - # the test to produce an incorrect result. To achieve this, - # we inserted a row in the future, and now we select the last - # inserted row back. - last_stored_statistic = stored_statistic[-1] - check_columns( - schema_errors, - last_stored_statistic, - statistics, - ("max", "mean", "min", "state", "sum"), - table.__tablename__, - "double precision", - ) - assert statistics["last_reset"] - check_columns( - schema_errors, - last_stored_statistic, - { - "last_reset": datetime_to_timestamp_or_none( - statistics["last_reset"] - ), - "start": datetime_to_timestamp_or_none(statistics["start"]), - }, - ("start", "last_reset"), - table.__tablename__, - "µs precision", - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - - return schema_errors - - -def validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - schema_errors |= _validate_db_schema_utf8(instance, session_maker) - schema_errors |= _validate_db_schema(hass, instance, session_maker) + for table in (Statistics, StatisticsShortTerm): + schema_errors |= validate_db_schema_precision(instance, table) if schema_errors: _LOGGER.debug( "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) @@ -233,63 +35,9 @@ def validate_db_schema( def correct_db_schema( instance: Recorder, - engine: Engine, - session_maker: Callable[[], Session], schema_errors: set[str], ) -> None: """Correct issues detected by validate_db_schema.""" - from ...migration import _modify_columns # pylint: disable=import-outside-toplevel - - if "statistics_meta.4-byte UTF-8" in schema_errors: - # Attempt to convert the table to utf8mb4 - _LOGGER.warning( - ( - "Updating character set and collation of table %s to utf8mb4. " - "Note: this can take several minutes on large databases and slow " - "computers. Please be patient!" - ), - "statistics_meta", - ) - with contextlib.suppress(SQLAlchemyError), session_scope( - session=session_maker() - ) as session: - connection = session.connection() - connection.execute( - # Using LOCK=EXCLUSIVE to prevent the database from corrupting - # https://github.com/home-assistant/core/issues/56104 - text( - "ALTER TABLE statistics_meta CONVERT TO CHARACTER SET utf8mb4" - " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" - ) - ) - - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, - ) - for table in tables: - if f"{table.__tablename__}.double precision" in schema_errors: - # Attempt to convert float columns to double precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", - ], - ) - if f"{table.__tablename__}.µs precision" in schema_errors: - # Attempt to convert timestamp columns to µs precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "last_reset_ts DOUBLE PRECISION", - "start_ts DOUBLE PRECISION", - ], - ) + correct_db_schema_utf8(instance, StatisticsMeta, schema_errors) + for table in (Statistics, StatisticsShortTerm): + correct_db_schema_precision(instance, table, schema_errors) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 0bb0b846a3..cf4c0543c1 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -119,13 +119,17 @@ STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" LEGACY_STATES_EVENT_ID_INDEX = "ix_states_event_id" CONTEXT_ID_BIN_MAX_LENGTH = 16 +MYSQL_COLLATE = "utf8mb4_unicode_ci" +MYSQL_DEFAULT_CHARSET = "utf8mb4" +MYSQL_ENGINE = "InnoDB" + _DEFAULT_TABLE_ARGS = { - "mysql_default_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - "mysql_engine": "InnoDB", - "mariadb_default_charset": "utf8mb4", - "mariadb_collate": "utf8mb4_unicode_ci", - "mariadb_engine": "InnoDB", + "mysql_default_charset": MYSQL_DEFAULT_CHARSET, + "mysql_collate": MYSQL_COLLATE, + "mysql_engine": MYSQL_ENGINE, + "mariadb_default_charset": MYSQL_DEFAULT_CHARSET, + "mariadb_collate": MYSQL_COLLATE, + "mariadb_engine": MYSQL_ENGINE, } @@ -154,6 +158,7 @@ DOUBLE_TYPE = ( .with_variant(oracle.DOUBLE_PRECISION(), "oracle") .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) +DOUBLE_PRECISION_TYPE_SQL = "DOUBLE PRECISION" TIMESTAMP_TYPE = DOUBLE_TYPE diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 6fc2138d91..927097b18f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -28,6 +28,10 @@ from homeassistant.core import HomeAssistant from homeassistant.util.enum import try_parse_enum from homeassistant.util.ulid import ulid_to_bytes +from .auto_repairs.states.schema import ( + correct_db_schema as states_correct_db_schema, + validate_db_schema as states_validate_db_schema, +) from .auto_repairs.statistics.duplicates import ( delete_statistics_duplicates, delete_statistics_meta_duplicates, @@ -39,7 +43,10 @@ from .auto_repairs.statistics.schema import ( from .const import SupportedDialect from .db_schema import ( CONTEXT_ID_BIN_MAX_LENGTH, + DOUBLE_PRECISION_TYPE_SQL, LEGACY_STATES_EVENT_ID_INDEX, + MYSQL_COLLATE, + MYSQL_DEFAULT_CHARSET, SCHEMA_VERSION, STATISTICS_TABLES, TABLE_STATES, @@ -96,13 +103,13 @@ class _ColumnTypesForDialect: _MYSQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER(20)", - timestamp_type="DOUBLE PRECISION", + timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type=f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})", ) _POSTGRESQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER", - timestamp_type="DOUBLE PRECISION", + timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type="BYTEA", ) @@ -151,7 +158,7 @@ class SchemaValidationStatus: """Store schema validation status.""" current_version: int - statistics_schema_errors: set[str] + schema_errors: set[str] valid: bool @@ -178,13 +185,23 @@ def validate_db_schema( if is_current := _schema_is_current(current_version): # We can only check for further errors if the schema is current, because # columns may otherwise not exist etc. - schema_errors |= statistics_validate_db_schema(hass, instance, session_maker) + schema_errors = _find_schema_errors(hass, instance, session_maker) valid = is_current and not schema_errors return SchemaValidationStatus(current_version, schema_errors, valid) +def _find_schema_errors( + hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] +) -> set[str]: + """Find schema errors.""" + schema_errors: set[str] = set() + schema_errors |= statistics_validate_db_schema(instance) + schema_errors |= states_validate_db_schema(instance) + return schema_errors + + def live_migration(schema_status: SchemaValidationStatus) -> bool: """Check if live migration is possible.""" return schema_status.current_version >= LIVE_MIGRATION_MIN_SCHEMA_VERSION @@ -226,12 +243,13 @@ def migrate_schema( # so its clear that the upgrade is done _LOGGER.warning("Upgrade to version %s done", new_version) - if schema_errors := schema_status.statistics_schema_errors: + if schema_errors := schema_status.schema_errors: _LOGGER.warning( "Database is about to correct DB schema errors: %s", ", ".join(sorted(schema_errors)), ) - statistics_correct_db_schema(instance, engine, session_maker, schema_errors) + statistics_correct_db_schema(instance, schema_errors) + states_correct_db_schema(instance, schema_errors) if current_version != SCHEMA_VERSION: instance.queue_task(PostSchemaMigrationTask(current_version, SCHEMA_VERSION)) @@ -732,38 +750,15 @@ def _apply_update( # noqa: C901 engine, "statistics", [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", + f"{column} {DOUBLE_PRECISION_TYPE_SQL}" + for column in ("max", "mean", "min", "state", "sum") ], ) elif new_version == 21: # Try to change the character set of the statistic_meta table if engine.dialect.name == SupportedDialect.MYSQL: for table in ("events", "states", "statistics_meta"): - _LOGGER.warning( - ( - "Updating character set and collation of table %s to utf8mb4." - " Note: this can take several minutes on large databases and" - " slow computers. Please be patient!" - ), - table, - ) - with contextlib.suppress(SQLAlchemyError), session_scope( - session=session_maker() - ) as session: - connection = session.connection() - connection.execute( - # Using LOCK=EXCLUSIVE to prevent - # the database from corrupting - # https://github.com/home-assistant/core/issues/56104 - text( - f"ALTER TABLE {table} CONVERT TO CHARACTER SET utf8mb4" - " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" - ) - ) + _correct_table_character_set_and_collation(table, session_maker) elif new_version == 22: # Recreate the all statistics tables for Oracle DB with Identity columns # @@ -1090,6 +1085,33 @@ def _apply_update( # noqa: C901 raise ValueError(f"No schema migration defined for version {new_version}") +def _correct_table_character_set_and_collation( + table: str, + session_maker: Callable[[], Session], +) -> None: + """Correct issues detected by validate_db_schema.""" + # Attempt to convert the table to utf8mb4 + _LOGGER.warning( + "Updating character set and collation of table %s to utf8mb4. " + "Note: this can take several minutes on large databases and slow " + "computers. Please be patient!", + table, + ) + with contextlib.suppress(SQLAlchemyError), session_scope( + session=session_maker() + ) as session: + connection = session.connection() + connection.execute( + # Using LOCK=EXCLUSIVE to prevent the database from corrupting + # https://github.com/home-assistant/core/issues/56104 + text( + f"ALTER TABLE {table} CONVERT TO CHARACTER SET " + f"{MYSQL_DEFAULT_CHARSET} " + f"COLLATE {MYSQL_COLLATE}, LOCK=EXCLUSIVE" + ) + ) + + def post_schema_migration( instance: Recorder, old_version: int, diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 9430158fae..6394f84222 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -102,7 +102,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "enable_custom_integrations": "None", "enable_nightly_purge": "bool", "enable_statistics": "bool", - "enable_statistics_table_validation": "bool", + "enable_schema_validation": "bool", "entity_registry": "EntityRegistry", "freezer": "FrozenDateTimeFactory", "hass_access_token": "str", diff --git a/tests/components/recorder/auto_repairs/states/__init__.py b/tests/components/recorder/auto_repairs/states/__init__.py new file mode 100644 index 0000000000..6e98d881ea --- /dev/null +++ b/tests/components/recorder/auto_repairs/states/__init__.py @@ -0,0 +1,5 @@ +"""Tests for Recorder component.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.recorder.common") diff --git a/tests/components/recorder/auto_repairs/states/test_schema.py b/tests/components/recorder/auto_repairs/states/test_schema.py new file mode 100644 index 0000000000..2e37001582 --- /dev/null +++ b/tests/components/recorder/auto_repairs/states/test_schema.py @@ -0,0 +1,106 @@ +"""The test repairing states schema.""" + +# pylint: disable=invalid-name +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.core import HomeAssistant + +from ...common import async_wait_recording_done + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +async def test_validate_db_schema_fix_float_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, +) -> None: + """Test validating DB schema with postgresql and mysql. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", + return_value={"states.double precision"}, + ), patch( + "homeassistant.components.recorder.migration._modify_columns" + ) as modify_columns_mock: + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: states.double precision" + in caplog.text + ) + modification = [ + "last_changed_ts DOUBLE PRECISION", + "last_updated_ts DOUBLE PRECISION", + ] + modify_columns_mock.assert_called_once_with(ANY, ANY, "states", modification) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_utf8_issue_states( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"states.4-byte UTF-8"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: states.4-byte UTF-8" + in caplog.text + ) + assert ( + "Updating character set and collation of table states to utf8mb4" in caplog.text + ) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_utf8_issue_state_attributes( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"state_attributes.4-byte UTF-8"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: state_attributes.4-byte UTF-8" + in caplog.text + ) + assert ( + "Updating character set and collation of table state_attributes to utf8mb4" + in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 2c4e06580f..dfe036355a 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -1,52 +1,18 @@ """The test repairing statistics schema.""" # pylint: disable=invalid-name -from datetime import datetime -from unittest.mock import ANY, DEFAULT, MagicMock, patch +from unittest.mock import ANY, patch import pytest -from sqlalchemy.exc import OperationalError -from homeassistant.components.recorder.auto_repairs.statistics.schema import ( - _get_future_year, -) -from homeassistant.components.recorder.statistics import ( - _statistics_during_period_with_session, -) -from homeassistant.components.recorder.table_managers.statistics_meta import ( - StatisticsMetaManager, -) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util from ...common import async_wait_recording_done from tests.typing import RecorderInstanceGenerator -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) -async def test_validate_db_schema( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, -) -> None: - """Test validating DB schema with MySQL and PostgreSQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ): - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - assert "Schema validation failed" not in caplog.text - assert "Detected statistics schema errors" not in caplog.text - assert "Database is about to correct DB schema errors" not in caplog.text - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize("enable_schema_validation", [True]) async def test_validate_db_schema_fix_utf8_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, @@ -56,15 +22,11 @@ async def test_validate_db_schema_fix_utf8_issue( Note: The test uses SQLite, the purpose is only to exercise the code. """ - orig_error = MagicMock() - orig_error.args = [1366] - utf8_error = OperationalError("", "", orig=orig_error) with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" ), patch( - "homeassistant.components.recorder.table_managers.statistics_meta.StatisticsMetaManager.update_or_add", - wraps=StatisticsMetaManager.update_or_add, - side_effect=[utf8_error, DEFAULT, DEFAULT], + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"statistics_meta.4-byte UTF-8"}, ): await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) @@ -80,60 +42,25 @@ async def test_validate_db_schema_fix_utf8_issue( ) -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("table", ("statistics_short_term", "statistics")) @pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) -@pytest.mark.parametrize( - ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) -) -@pytest.mark.parametrize( - ("column", "value"), - (("max", 1.0), ("mean", 1.0), ("min", 1.0), ("state", 1.0), ("sum", 1.0)), -) async def test_validate_db_schema_fix_float_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - db_engine, - table, - replace_index, - column, - value, + table: str, + db_engine: str, ) -> None: - """Test validating DB schema with MySQL. + """Test validating DB schema with postgresql and mysql. Note: The test uses SQLite, the purpose is only to exercise the code. """ - orig_error = MagicMock() - orig_error.args = [1366] - precise_number = 1.000000000000001 - fixed_future_year = _get_future_year() - precise_time = datetime(fixed_future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - statistics = { - "recorder.db_test": [ - { - "last_reset": precise_time.timestamp(), - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time.timestamp(), - "state": precise_number, - "sum": precise_number, - } - ] - } - statistics["recorder.db_test"][0][column] = value - fake_statistics = [DEFAULT, DEFAULT] - fake_statistics[replace_index] = statistics - with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine ), patch( - "homeassistant.components.recorder.auto_repairs.statistics.schema._get_future_year", - return_value=fixed_future_year, - ), patch( - "homeassistant.components.recorder.auto_repairs.statistics.schema._statistics_during_period_with_session", - side_effect=fake_statistics, - wraps=_statistics_during_period_with_session, + "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", + return_value={f"{table}.double precision"}, ), patch( "homeassistant.components.recorder.migration._modify_columns" ) as modify_columns_mock: @@ -146,90 +73,13 @@ async def test_validate_db_schema_fix_float_issue( in caplog.text ) modification = [ + "created_ts DOUBLE PRECISION", + "start_ts DOUBLE PRECISION", "mean DOUBLE PRECISION", "min DOUBLE PRECISION", "max DOUBLE PRECISION", + "last_reset_ts DOUBLE PRECISION", "state DOUBLE PRECISION", "sum DOUBLE PRECISION", ] modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize( - ("db_engine", "modification"), - ( - ("mysql", ["last_reset_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION"]), - ( - "postgresql", - [ - "last_reset_ts DOUBLE PRECISION", - "start_ts DOUBLE PRECISION", - ], - ), - ), -) -@pytest.mark.parametrize( - ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) -) -@pytest.mark.parametrize( - ("column", "value"), - ( - ("last_reset", "2020-10-06T00:00:00+00:00"), - ("start", "2020-10-06T00:00:00+00:00"), - ), -) -async def test_validate_db_schema_fix_statistics_datetime_issue( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, - modification, - table, - replace_index, - column, - value, -) -> None: - """Test validating DB schema with MySQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - orig_error = MagicMock() - orig_error.args = [1366] - precise_number = 1.000000000000001 - precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - statistics = { - "recorder.db_test": [ - { - "last_reset": precise_time, - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time, - "state": precise_number, - "sum": precise_number, - } - ] - } - statistics["recorder.db_test"][0][column] = value - fake_statistics = [DEFAULT, DEFAULT] - fake_statistics[replace_index] = statistics - - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ), patch( - "homeassistant.components.recorder.auto_repairs.statistics.schema._statistics_during_period_with_session", - side_effect=fake_statistics, - wraps=_statistics_during_period_with_session, - ), patch( - "homeassistant.components.recorder.migration._modify_columns" - ) as modify_columns_mock: - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - assert "Schema validation failed" not in caplog.text - assert ( - f"Database is about to correct DB schema errors: {table}.µs precision" - in caplog.text - ) - modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py new file mode 100644 index 0000000000..510f46f98a --- /dev/null +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -0,0 +1,253 @@ +"""The test validating and repairing schema.""" + +# pylint: disable=invalid-name +from unittest.mock import patch + +import pytest +from sqlalchemy import text + +from homeassistant.components.recorder.auto_repairs.schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) +from homeassistant.components.recorder.db_schema import States +from homeassistant.components.recorder.migration import _modify_columns +from homeassistant.components.recorder.util import get_instance, session_scope +from homeassistant.core import HomeAssistant + +from ..common import async_wait_recording_done + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +async def test_validate_db_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, +) -> None: + """Test validating DB schema with MySQL and PostgreSQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + assert "Schema validation failed" not in caplog.text + assert "Detected statistics schema errors" not in caplog.text + assert "Database is about to correct DB schema errors" not in caplog.text + + +async def test_validate_db_schema_fix_utf8_issue_good_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL when the schema is correct.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, (States.state,) + ) + assert schema_errors == set() + + +async def test_validate_db_schema_fix_utf8_issue_with_broken_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL when the schema is broken and repairing it.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + with session_scope(session=session_maker()) as session: + session.execute( + text( + "ALTER TABLE states MODIFY state VARCHAR(255) " + "CHARACTER SET ascii COLLATE ascii_general_ci, " + "LOCK=EXCLUSIVE;" + ) + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, (States.state,) + ) + assert schema_errors == {"states.4-byte UTF-8"} + + # Now repair the schema + await instance.async_add_executor_job( + correct_db_schema_utf8, instance, States, schema_errors + ) + + # Now validate the schema again + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, ("state",) + ) + assert schema_errors == set() + + +async def test_validate_db_schema_fix_utf8_issue_with_broken_schema_unrepairable( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL when the schema is broken and cannot be repaired.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + with session_scope(session=session_maker()) as session: + session.execute( + text( + "ALTER TABLE states MODIFY state VARCHAR(255) " + "CHARACTER SET ascii COLLATE ascii_general_ci, " + "LOCK=EXCLUSIVE;" + ) + ) + _modify_columns( + session_maker, + instance.engine, + "states", + [ + "entity_id VARCHAR(255) NOT NULL", + ], + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, ("state",) + ) + assert schema_errors == set() + assert "Error when validating DB schema" in caplog.text + + +async def test_validate_db_schema_precision_good_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema when the schema is correct.""" + if not recorder_db_url.startswith(("mysql://", "postgresql://")): + # This problem only happens on MySQL and PostgreSQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert schema_errors == set() + + +async def test_validate_db_schema_precision_with_broken_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema when the schema is broken and than repair it.""" + if not recorder_db_url.startswith(("mysql://", "postgresql://")): + # This problem only happens on MySQL and PostgreSQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + _modify_columns( + session_maker, + instance.engine, + "states", + [ + "last_updated_ts FLOAT(4)", + "last_changed_ts FLOAT(4)", + ], + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert schema_errors == {"states.double precision"} + + # Now repair the schema + await instance.async_add_executor_job( + correct_db_schema_precision, instance, States, schema_errors + ) + + # Now validate the schema again + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert schema_errors == set() + + +async def test_validate_db_schema_precision_with_unrepairable_broken_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema when the schema is broken and cannot be repaired.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + _modify_columns( + session_maker, + instance.engine, + "states", + [ + "state VARCHAR(255) NOT NULL", + "last_updated_ts FLOAT(4)", + "last_changed_ts FLOAT(4)", + ], + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert "Error when validating DB schema" in caplog.text + assert schema_errors == set() diff --git a/tests/conftest.py b/tests/conftest.py index c5197dd2bd..397d3d55b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1161,11 +1161,11 @@ def enable_statistics() -> bool: @pytest.fixture -def enable_statistics_table_validation() -> bool: +def enable_schema_validation() -> bool: """Fixture to control enabling of recorder's statistics table validation. To enable statistics table validation, tests can be marked with: - @pytest.mark.parametrize("enable_statistics_table_validation", [True]) + @pytest.mark.parametrize("enable_schema_validation", [True]) """ return False @@ -1272,7 +1272,7 @@ def hass_recorder( recorder_db_url: str, enable_nightly_purge: bool, enable_statistics: bool, - enable_statistics_table_validation: bool, + enable_schema_validation: bool, enable_migrate_context_ids: bool, enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, @@ -1283,16 +1283,16 @@ def hass_recorder( from homeassistant.components import recorder # pylint: disable-next=import-outside-toplevel - from homeassistant.components.recorder.auto_repairs.statistics import schema + from homeassistant.components.recorder import migration original_tz = dt_util.DEFAULT_TIME_ZONE hass = get_test_home_assistant() nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None - stats_validate = ( - schema.validate_db_schema - if enable_statistics_table_validation + schema_validate = ( + migration._find_schema_errors + if enable_schema_validation else itertools.repeat(set()) ) migrate_states_context_ids = ( @@ -1322,8 +1322,8 @@ def hass_recorder( side_effect=stats, autospec=True, ), patch( - "homeassistant.components.recorder.migration.statistics_validate_db_schema", - side_effect=stats_validate, + "homeassistant.components.recorder.migration._find_schema_errors", + side_effect=schema_validate, autospec=True, ), patch( "homeassistant.components.recorder.Recorder._migrate_events_context_ids", @@ -1391,7 +1391,7 @@ async def async_setup_recorder_instance( recorder_db_url: str, enable_nightly_purge: bool, enable_statistics: bool, - enable_statistics_table_validation: bool, + enable_schema_validation: bool, enable_migrate_context_ids: bool, enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, @@ -1401,16 +1401,16 @@ async def async_setup_recorder_instance( from homeassistant.components import recorder # pylint: disable-next=import-outside-toplevel - from homeassistant.components.recorder.auto_repairs.statistics import schema + from homeassistant.components.recorder import migration # pylint: disable-next=import-outside-toplevel from .components.recorder.common import async_recorder_block_till_done nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None - stats_validate = ( - schema.validate_db_schema - if enable_statistics_table_validation + schema_validate = ( + migration._find_schema_errors + if enable_schema_validation else itertools.repeat(set()) ) migrate_states_context_ids = ( @@ -1440,8 +1440,8 @@ async def async_setup_recorder_instance( side_effect=stats, autospec=True, ), patch( - "homeassistant.components.recorder.migration.statistics_validate_db_schema", - side_effect=stats_validate, + "homeassistant.components.recorder.migration._find_schema_errors", + side_effect=schema_validate, autospec=True, ), patch( "homeassistant.components.recorder.Recorder._migrate_events_context_ids", From 03aeaba7ef6fc0093cca1d9e9984796b0d473ad4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 22 Mar 2023 22:34:23 +0100 Subject: [PATCH 0044/1165] Turn AVM FRITZ!Box Tools sensors into coordinator entities (#89953) * make sensors coordinator entities * apply suggestions * move _attr_has_entity_name up --- homeassistant/components/fritz/common.py | 85 +++++++++++++++++++++++- homeassistant/components/fritz/sensor.py | 53 ++++----------- 2 files changed, 95 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 09103a0bcc..f6025e773e 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -35,7 +35,8 @@ from homeassistant.helpers import ( update_coordinator, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ( @@ -136,7 +137,9 @@ class HostInfo(TypedDict): status: bool -class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): +class FritzBoxTools( + update_coordinator.DataUpdateCoordinator[dict[str, bool | StateType]] +): """FritzBoxTools class.""" def __init__( @@ -175,6 +178,9 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): self._latest_firmware: str | None = None self._update_available: bool = False self._release_url: str | None = None + self._entity_update_functions: dict[ + str, Callable[[FritzStatus, StateType], Any] + ] = {} async def async_setup( self, options: MappingProxyType[str, Any] | None = None @@ -237,12 +243,36 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): ) self.device_is_router = self.fritz_status.has_wan_enabled - async def _async_update_data(self) -> None: + def register_entity_updates( + self, key: str, update_fn: Callable[[FritzStatus, StateType], Any] + ) -> Callable[[], None]: + """Register an entity to be updated by coordinator.""" + + def unregister_entity_updates() -> None: + """Unregister an entity to be updated by coordinator.""" + if key in self._entity_update_functions: + _LOGGER.debug("unregister entity %s from updates", key) + self._entity_update_functions.pop(key) + + if key not in self._entity_update_functions: + _LOGGER.debug("register entity %s for updates", key) + self._entity_update_functions[key] = update_fn + return unregister_entity_updates + + async def _async_update_data(self) -> dict[str, bool | StateType]: """Update FritzboxTools data.""" + enity_data: dict[str, bool | StateType] = {} try: await self.async_scan_devices() + for key, update_fn in self._entity_update_functions.items(): + _LOGGER.debug("update entity %s", key) + enity_data[key] = await self.hass.async_add_executor_job( + update_fn, self.fritz_status, self.data.get(key) + ) except FRITZ_EXCEPTIONS as ex: raise update_coordinator.UpdateFailed(ex) from ex + _LOGGER.debug("enity_data: %s", enity_data) + return enity_data @property def unique_id(self) -> str: @@ -981,6 +1011,55 @@ class FritzBoxBaseEntity: ) +@dataclass +class FritzRequireKeysMixin: + """Fritz entity description mix in.""" + + value_fn: Callable[[FritzStatus, Any], Any] + + +@dataclass +class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin): + """Fritz entity base description.""" + + +class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity): + """Fritz host coordinator entity base class.""" + + coordinator: AvmWrapper + entity_description: FritzEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + avm_wrapper: AvmWrapper, + device_name: str, + description: FritzEntityDescription, + ) -> None: + """Init device info class.""" + super().__init__(avm_wrapper) + self.async_on_remove( + avm_wrapper.register_entity_updates(description.key, description.value_fn) + ) + self.entity_description = description + self._device_name = device_name + self._attr_name = description.name + self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + configuration_url=f"http://{self.coordinator.host}", + connections={(dr.CONNECTION_NETWORK_MAC, self.coordinator.mac)}, + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer="AVM", + model=self.coordinator.model, + name=self._device_name, + sw_version=self.coordinator.current_firmware, + ) + + @dataclass class ConnectionInfo: """Fritz sensor connection information class.""" diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 628d56dc45..4b15f3f92d 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -5,9 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta import logging -from typing import Any -from fritzconnection.core.exceptions import FritzConnectionException from fritzconnection.lib.fritzstatus import FritzStatus from homeassistant.components.sensor import ( @@ -25,9 +23,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity +from .common import ( + AvmWrapper, + ConnectionInfo, + FritzBoxBaseCoordinatorEntity, + FritzEntityDescription, +) from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION _LOGGER = logging.getLogger(__name__) @@ -139,14 +143,7 @@ def _retrieve_link_attenuation_received_state( @dataclass -class FritzRequireKeysMixin: - """Fritz sensor data class.""" - - value_fn: Callable[[FritzStatus, Any], Any] - - -@dataclass -class FritzSensorEntityDescription(SensorEntityDescription, FritzRequireKeysMixin): +class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): """Describes Fritz sensor entity.""" is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled @@ -304,36 +301,12 @@ async def async_setup_entry( async_add_entities(entities, True) -class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): +class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity): """Define FRITZ!Box connectivity class.""" entity_description: FritzSensorEntityDescription - def __init__( - self, - avm_wrapper: AvmWrapper, - device_friendly_name: str, - description: FritzSensorEntityDescription, - ) -> None: - """Init FRITZ!Box connectivity class.""" - self.entity_description = description - self._last_device_value: str | None = None - self._attr_available = True - self._attr_name = f"{device_friendly_name} {description.name}" - self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" - super().__init__(avm_wrapper, device_friendly_name) - - def update(self) -> None: - """Update data.""" - _LOGGER.debug("Updating FRITZ!Box sensors") - - status: FritzStatus = self._avm_wrapper.fritz_status - try: - self._attr_native_value = ( - self._last_device_value - ) = self.entity_description.value_fn(status, self._last_device_value) - except FritzConnectionException: - _LOGGER.error("Error getting the state from the FRITZ!Box", exc_info=True) - self._attr_available = False - return - self._attr_available = True + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data.get(self.entity_description.key) From 99b58f157ec9e88fe6e97db1772631f9b5da90e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 14:00:47 -1000 Subject: [PATCH 0045/1165] Bump PyJWT to 2.6.0 (#90134) * Bump PyJWT to 2.6.0 * fix time being frozen too late which makes the access token creation time in the future * revert zha change * fix repairs test * fix ical test --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/alexa/test_smart_home.py | 25 +++---- .../bmw_connected_drive/test_diagnostics.py | 8 +- tests/components/ipma/test_weather.py | 4 +- .../local_calendar/test_diagnostics.py | 74 ++++++++++++++++--- tests/components/metoffice/test_init.py | 5 +- tests/components/metoffice/test_sensor.py | 10 ++- tests/components/metoffice/test_weather.py | 18 +++-- tests/components/recorder/test_util.py | 3 +- .../components/recorder/test_websocket_api.py | 12 ++- tests/components/repairs/test_init.py | 8 +- .../components/repairs/test_websocket_api.py | 3 +- tests/components/shelly/test_utils.py | 3 +- tests/components/tod/test_binary_sensor.py | 23 +++--- tests/components/tod/test_config_flow.py | 3 +- tests/components/tod/test_init.py | 5 +- .../unifiprotect/test_media_source.py | 7 +- 19 files changed, 143 insertions(+), 74 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ffef59913e..0fcae3ec80 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,4 @@ -PyJWT==2.5.0 +PyJWT==2.6.0 PyNaCl==1.5.0 aiodiscover==1.4.14 aiohttp==3.8.4 diff --git a/pyproject.toml b/pyproject.toml index d8ba8e7475..5d39a99c03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "ifaddr==0.1.7", "jinja2==3.1.2", "lru-dict==1.1.8", - "PyJWT==2.5.0", + "PyJWT==2.6.0", # PyJWT has loose dependency. We want the latest one. "cryptography==39.0.1", # pyOpenSSL 23.0.0 is required to work with cryptography 39+ diff --git a/requirements.txt b/requirements.txt index 1b4874e2c4..2386015c84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ home-assistant-bluetooth==1.9.3 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 -PyJWT==2.5.0 +PyJWT==2.6.0 cryptography==39.0.1 pyOpenSSL==23.0.0 orjson==3.8.7 diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 49c6dc35ff..601f59fd11 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1,7 +1,6 @@ """Test for smart home alexa support.""" from unittest.mock import patch -from freezegun import freeze_time import pytest from homeassistant.components.alexa import messages, smart_home @@ -158,7 +157,7 @@ def assert_endpoint_capabilities(endpoint, *interfaces): return capabilities -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_switch(hass: HomeAssistant, events: list[Event]) -> None: """Test switch discovery.""" device = ("switch.test", "on", {"friendly_name": "Test switch"}) @@ -212,7 +211,7 @@ async def test_outlet(hass: HomeAssistant, events: list[Event]) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_light(hass: HomeAssistant) -> None: """Test light discovery.""" device = ("light.test_1", "on", {"friendly_name": "Test light 1"}) @@ -308,7 +307,7 @@ async def test_color_light( # tests -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_script(hass: HomeAssistant) -> None: """Test script discovery.""" device = ("script.test", "off", {"friendly_name": "Test script"}) @@ -329,7 +328,7 @@ async def test_script(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_input_boolean(hass: HomeAssistant) -> None: """Test input boolean discovery.""" device = ("input_boolean.test", "off", {"friendly_name": "Test input boolean"}) @@ -366,7 +365,7 @@ async def test_input_boolean(hass: HomeAssistant) -> None: assert {"name": "detectionState"} in properties["supported"] -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_scene(hass: HomeAssistant) -> None: """Test scene discovery.""" device = ("scene.test", "off", {"friendly_name": "Test scene"}) @@ -387,7 +386,7 @@ async def test_scene(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_fan(hass: HomeAssistant) -> None: """Test fan discovery.""" device = ("fan.test_1", "off", {"friendly_name": "Test fan 1"}) @@ -945,7 +944,7 @@ async def test_single_preset_mode_fan( caplog.clear() -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_humidifier( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1117,7 +1116,7 @@ async def test_lock(hass: HomeAssistant) -> None: assert properties["value"] == "UNLOCKED" -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_media_player(hass: HomeAssistant) -> None: """Test media player discovery.""" device = ( @@ -1729,7 +1728,7 @@ async def test_media_player_seek_error(hass: HomeAssistant) -> None: assert msg["payload"]["type"] == "ACTION_NOT_PERMITTED_FOR_CONTENT" -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_alert(hass: HomeAssistant) -> None: """Test alert discovery.""" device = ("alert.test", "off", {"friendly_name": "Test alert"}) @@ -1747,7 +1746,7 @@ async def test_alert(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_automation(hass: HomeAssistant) -> None: """Test automation discovery.""" device = ("automation.test", "off", {"friendly_name": "Test automation"}) @@ -1769,7 +1768,7 @@ async def test_automation(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_group(hass: HomeAssistant) -> None: """Test group discovery.""" device = ("group.test", "off", {"friendly_name": "Test group"}) @@ -4183,7 +4182,7 @@ async def test_initialize_camera_stream( ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") @pytest.mark.parametrize( "domain", ["button", "input_button"], diff --git a/tests/components/bmw_connected_drive/test_diagnostics.py b/tests/components/bmw_connected_drive/test_diagnostics.py index 5858ae2e52..a186a52bcd 100644 --- a/tests/components/bmw_connected_drive/test_diagnostics.py +++ b/tests/components/bmw_connected_drive/test_diagnostics.py @@ -4,7 +4,7 @@ import json import os import time -from freezegun import freeze_time +import pytest from homeassistant.components.bmw_connected_drive.const import DOMAIN from homeassistant.core import HomeAssistant @@ -20,7 +20,7 @@ from tests.components.diagnostics import ( from tests.typing import ClientSessionGenerator -@freeze_time(datetime.datetime(2022, 7, 10, 11)) +@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_config_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture ) -> None: @@ -43,7 +43,7 @@ async def test_config_entry_diagnostics( assert diagnostics == diagnostics_fixture -@freeze_time(datetime.datetime(2022, 7, 10, 11)) +@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture ) -> None: @@ -72,7 +72,7 @@ async def test_device_diagnostics( assert diagnostics == diagnostics_fixture -@freeze_time(datetime.datetime(2022, 7, 10, 11)) +@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics_vehicle_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture ) -> None: diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index c5b5a1298f..285f7ceacb 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -2,7 +2,7 @@ from datetime import datetime from unittest.mock import patch -from freezegun import freeze_time +import pytest from homeassistant.components.weather import ( ATTR_FORECAST, @@ -100,7 +100,7 @@ async def test_daily_forecast(hass: HomeAssistant) -> None: assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S" -@freeze_time("2020-01-14 23:00:00") +@pytest.mark.freeze_time("2020-01-14 23:00:00") async def test_hourly_forecast(hass: HomeAssistant) -> None: """Test for successfully getting daily forecast.""" with patch( diff --git a/tests/components/local_calendar/test_diagnostics.py b/tests/components/local_calendar/test_diagnostics.py index 8b033cf4fd..561f7588a5 100644 --- a/tests/components/local_calendar/test_diagnostics.py +++ b/tests/components/local_calendar/test_diagnostics.py @@ -1,19 +1,46 @@ """Tests for diagnostics platform of local calendar.""" +from aiohttp.test_utils import TestClient from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.auth.models import Credentials from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY, ClientFixture +from .conftest import TEST_ENTITY, Client, ClientFixture -from tests.common import MockConfigEntry +from tests.common import CLIENT_ID, MockConfigEntry, MockUser from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +async def generate_new_hass_access_token( + hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials +) -> str: + """Return an access token to access Home Assistant.""" + await hass.auth.async_link_user(hass_admin_user, hass_admin_credential) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_admin_user, CLIENT_ID, credential=hass_admin_credential + ) + return hass.auth.async_create_access_token(refresh_token) + + +def _get_test_client_generator( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, new_token: str +): + """Return a test client generator."".""" + + async def auth_client() -> TestClient: + return await aiohttp_client( + hass.http.app, headers={"Authorization": f"Bearer {new_token}"} + ) + + return auth_client + + @pytest.fixture(autouse=True) async def setup_diag(hass): """Set up diagnostics platform.""" @@ -24,12 +51,27 @@ async def setup_diag(hass): async def test_empty_calendar( hass: HomeAssistant, setup_integration: None, - hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + hass_admin_credential: Credentials, config_entry: MockConfigEntry, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics against an empty calendar.""" - data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + # Since we are freezing time only when we enter this test, we need to + # manually create a new token and clients since the token created by + # the fixtures would not be valid. + # + # Ideally we would use pytest.mark.freeze_time before the fixtures, but that does not + # work with the ical library and freezegun because + # `TypeError: '<' not supported between instances of 'FakeDatetimeMeta' and 'FakeDateMeta'` + new_token = await generate_new_hass_access_token( + hass, hass_admin_user, hass_admin_credential + ) + data = await get_diagnostics_for_config_entry( + hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry + ) assert data == snapshot @@ -37,14 +79,26 @@ async def test_empty_calendar( async def test_api_date_time_event( hass: HomeAssistant, setup_integration: None, + hass_admin_user: MockUser, + hass_admin_credential: Credentials, config_entry: MockConfigEntry, - hass_client: ClientSessionGenerator, - ws_client: ClientFixture, + hass_ws_client: ClientFixture, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, snapshot: SnapshotAssertion, ) -> None: """Test an event with a start/end date time.""" - - client = await ws_client() + # Since we are freezing time only when we enter this test, we need to + # manually create a new token and clients since the token created by + # the fixtures would not be valid. + # + # Ideally we would use pytest.mark.freeze_time before the fixtures, but that does not + # work with the ical library and freezegun because + # `TypeError: '<' not supported between instances of 'FakeDatetimeMeta' and 'FakeDateMeta'` + new_token = await generate_new_hass_access_token( + hass, hass_admin_user, hass_admin_credential + ) + client = Client(await hass_ws_client(hass, access_token=new_token)) await client.cmd_result( "create", { @@ -58,5 +112,7 @@ async def test_api_date_time_event( }, ) - data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + data = await get_diagnostics_for_config_entry( + hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry + ) assert data == snapshot diff --git a/tests/components/metoffice/test_init.py b/tests/components/metoffice/test_init.py index 917c031edb..f21f3a1b26 100644 --- a/tests/components/metoffice/test_init.py +++ b/tests/components/metoffice/test_init.py @@ -3,7 +3,6 @@ from __future__ import annotations import datetime -from freezegun import freeze_time import pytest import requests_mock @@ -16,7 +15,9 @@ from .const import DOMAIN, METOFFICE_CONFIG_WAVERTREE, TEST_COORDINATES_WAVERTRE from tests.common import MockConfigEntry -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) @pytest.mark.parametrize( ("old_unique_id", "new_unique_id", "migration_needed"), [ diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index d2e5d55355..28bf8eda99 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -2,7 +2,7 @@ import datetime import json -from freezegun import freeze_time +import pytest import requests_mock from homeassistant.components.metoffice.const import ATTRIBUTION, DOMAIN @@ -24,7 +24,9 @@ from .const import ( from tests.common import MockConfigEntry, load_fixture -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_one_sensor_site_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -72,7 +74,9 @@ async def test_one_sensor_site_running( assert sensor.attributes.get("attribution") == ATTRIBUTION -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_two_sensor_sites_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index d386004129..0e5a934c7d 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -3,7 +3,7 @@ import datetime from datetime import timedelta import json -from freezegun import freeze_time +import pytest import requests_mock from homeassistant.components.metoffice.const import DOMAIN @@ -23,7 +23,9 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_site_cannot_connect( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -52,7 +54,9 @@ async def test_site_cannot_connect( assert sensor is None -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_site_cannot_update( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -100,7 +104,9 @@ async def test_site_cannot_update( assert weather.state == STATE_UNAVAILABLE -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_one_weather_site_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -183,7 +189,9 @@ async def test_one_weather_site_running( assert weather.attributes.get("forecast")[3]["wind_bearing"] == "SE" -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_two_weather_sites_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 383a083843..4cc4f4b94a 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -6,7 +6,6 @@ from pathlib import Path import sqlite3 from unittest.mock import MagicMock, Mock, patch -from freezegun import freeze_time import py import pytest from sqlalchemy import text @@ -934,7 +933,7 @@ def test_execute_stmt_lambda_element( assert rows == ["mock_row"] -@freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) +@pytest.mark.freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) async def test_resolve_period(hass: HomeAssistant) -> None: """Test statistic_during_period.""" diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 5244a33f0b..8e760b4010 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -217,7 +217,9 @@ async def test_statistics_during_period( } -@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) +) @pytest.mark.parametrize("offset", (0, 1, 2)) async def test_statistic_during_period( recorder_mock: Recorder, @@ -632,7 +634,9 @@ async def test_statistic_during_period( } -@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) +) async def test_statistic_during_period_hole( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -795,7 +799,9 @@ async def test_statistic_during_period_hole( } -@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) +) @pytest.mark.parametrize( ("calendar_period", "start_time", "end_time"), ( diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index bae71e71e2..ce787ad00b 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -1,7 +1,6 @@ """Test the repairs websocket API.""" from unittest.mock import AsyncMock, Mock -from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -27,7 +26,7 @@ from tests.common import mock_platform from tests.typing import WebSocketGenerator -@freeze_time("2022-07-19 07:53:05") +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_create_update_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -166,7 +165,7 @@ async def test_create_issue_invalid_version( assert msg["result"] == {"issues": []} -@freeze_time("2022-07-19 07:53:05") +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_ignore_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -335,6 +334,7 @@ async def test_ignore_issue( } +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_delete_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -487,7 +487,7 @@ async def test_non_compliant_platform( assert list(hass.data[DOMAIN]["platforms"].keys()) == ["fake_integration"] -@freeze_time("2022-07-21 08:22:00") +@pytest.mark.freeze_time("2022-07-21 08:22:00") async def test_sync_methods( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 4db5b6a9d1..c82337b484 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -5,7 +5,6 @@ from http import HTTPStatus from typing import Any from unittest.mock import ANY, AsyncMock, Mock -from freezegun import freeze_time import pytest import voluptuous as vol @@ -430,7 +429,7 @@ async def test_step_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@freeze_time("2022-07-19 07:53:05") +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_list_issues( hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client ) -> None: diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index b2be776098..701e8b487c 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -1,5 +1,4 @@ """Tests for Shelly utils.""" -from freezegun import freeze_time import pytest from homeassistant.components.shelly.utils import ( @@ -150,7 +149,7 @@ async def test_get_block_device_sleep_period(settings, sleep_period) -> None: assert get_block_device_sleep_period(settings) == sleep_period -@freeze_time("2019-01-10 18:43:00+00:00") +@pytest.mark.freeze_time("2019-01-10 18:43:00+00:00") async def test_get_device_uptime() -> None: """Test block test get device uptime.""" assert get_device_uptime( diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index 0f0a145645..c1823c23f8 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -1,7 +1,6 @@ """Test Times of the Day Binary Sensor.""" from datetime import datetime, timedelta -from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -67,7 +66,7 @@ async def test_setup_no_sensors(hass: HomeAssistant) -> None: ) -@freeze_time("2019-01-10 18:43:00-08:00") +@pytest.mark.freeze_time("2019-01-10 18:43:00-08:00") async def test_in_period_on_start(hass: HomeAssistant) -> None: """Test simple setting.""" config = { @@ -87,7 +86,7 @@ async def test_in_period_on_start(hass: HomeAssistant) -> None: assert state.state == STATE_ON -@freeze_time("2019-01-10 22:30:00-08:00") +@pytest.mark.freeze_time("2019-01-10 22:30:00-08:00") async def test_midnight_turnover_before_midnight_inside_period( hass: HomeAssistant, ) -> None: @@ -131,7 +130,7 @@ async def test_midnight_turnover_after_midnight_inside_period( assert state.state == STATE_ON -@freeze_time("2019-01-10 20:30:00-08:00") +@pytest.mark.freeze_time("2019-01-10 20:30:00-08:00") async def test_midnight_turnover_before_midnight_outside_period( hass: HomeAssistant, ) -> None: @@ -148,7 +147,7 @@ async def test_midnight_turnover_before_midnight_outside_period( assert state.state == STATE_OFF -@freeze_time("2019-01-10 10:00:00-08:00") +@pytest.mark.freeze_time("2019-01-10 10:00:00-08:00") async def test_after_happens_tomorrow(hass: HomeAssistant) -> None: """Test when both before and after are in the future, and after is later than before.""" config = { @@ -643,7 +642,7 @@ async def test_dst( assert state.state == STATE_OFF -@freeze_time("2019-01-10 18:43:00") +@pytest.mark.freeze_time("2019-01-10 18:43:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_not_in_range( hass: HomeAssistant, @@ -669,7 +668,7 @@ async def test_simple_before_after_does_not_loop_utc_not_in_range( assert state.attributes["next_update"] == "2019-01-10T22:00:00+00:00" -@freeze_time("2019-01-10 22:43:00") +@pytest.mark.freeze_time("2019-01-10 22:43:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_in_range( hass: HomeAssistant, @@ -695,7 +694,7 @@ async def test_simple_before_after_does_not_loop_utc_in_range( assert state.attributes["next_update"] == "2019-01-11T06:00:00+00:00" -@freeze_time("2019-01-11 06:00:00") +@pytest.mark.freeze_time("2019-01-11 06:00:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_fire_at_before( hass: HomeAssistant, @@ -721,7 +720,7 @@ async def test_simple_before_after_does_not_loop_utc_fire_at_before( assert state.attributes["next_update"] == "2019-01-11T22:00:00+00:00" -@freeze_time("2019-01-10 22:00:00") +@pytest.mark.freeze_time("2019-01-10 22:00:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_fire_at_after( hass: HomeAssistant, @@ -747,7 +746,7 @@ async def test_simple_before_after_does_not_loop_utc_fire_at_after( assert state.attributes["next_update"] == "2019-01-11T06:00:00+00:00" -@freeze_time("2019-01-10 22:00:00") +@pytest.mark.freeze_time("2019-01-10 22:00:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_both_before_now( hass: HomeAssistant, @@ -773,7 +772,7 @@ async def test_simple_before_after_does_not_loop_utc_both_before_now( assert state.attributes["next_update"] == "2019-01-11T00:00:00+00:00" -@freeze_time("2019-01-10 17:43:00+01:00") +@pytest.mark.freeze_time("2019-01-10 17:43:00+01:00") @pytest.mark.parametrize("hass_time_zone", ("Europe/Berlin",)) async def test_simple_before_after_does_not_loop_berlin_not_in_range( hass: HomeAssistant, @@ -799,7 +798,7 @@ async def test_simple_before_after_does_not_loop_berlin_not_in_range( assert state.attributes["next_update"] == "2019-01-11T00:00:00+01:00" -@freeze_time("2019-01-11 00:43:00+01:00") +@pytest.mark.freeze_time("2019-01-11 00:43:00+01:00") @pytest.mark.parametrize("hass_time_zone", ("Europe/Berlin",)) async def test_simple_before_after_does_not_loop_berlin_in_range( hass: HomeAssistant, diff --git a/tests/components/tod/test_config_flow.py b/tests/components/tod/test_config_flow.py index 4d0e2a0619..6860d401ce 100644 --- a/tests/components/tod/test_config_flow.py +++ b/tests/components/tod/test_config_flow.py @@ -1,7 +1,6 @@ """Test the Times of the Day config flow.""" from unittest.mock import patch -from freezegun import freeze_time import pytest from homeassistant import config_entries @@ -66,7 +65,7 @@ def get_suggested(schema, key): raise Exception -@freeze_time("2022-03-16 17:37:00", tz_offset=-7) +@pytest.mark.freeze_time("2022-03-16 17:37:00", tz_offset=-7) async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" # Setup the config entry diff --git a/tests/components/tod/test_init.py b/tests/components/tod/test_init.py index 510bf848ad..4a9f55bdec 100644 --- a/tests/components/tod/test_init.py +++ b/tests/components/tod/test_init.py @@ -1,5 +1,6 @@ """Test the Times of the Day integration.""" -from freezegun import freeze_time + +import pytest from homeassistant.components.tod.const import DOMAIN from homeassistant.core import HomeAssistant @@ -8,7 +9,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry -@freeze_time("2022-03-16 17:37:00", tz_offset=-7) +@pytest.mark.freeze_time("2022-03-16 17:37:00", tz_offset=-7) async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None: """Test setting up and removing a config entry.""" registry = er.async_get(hass) diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 1df0fbb168..e19985aea3 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta from ipaddress import IPv4Address from unittest.mock import AsyncMock, Mock, patch -from freezegun import freeze_time import pytest import pytz from pyunifiprotect.data import ( @@ -465,7 +464,7 @@ TWO_MONTH_SIMPLE = ( ("start", "months"), [ONE_MONTH_SIMPLE, TWO_MONTH_SIMPLE], ) -@freeze_time("2022-09-15 03:00:00-07:00") +@pytest.mark.freeze_time("2022-09-15 03:00:00-07:00") async def test_browse_media_time( hass: HomeAssistant, ufp: MockUFPFixture, @@ -537,7 +536,7 @@ TWO_MONTH_TIMEZONE = ( ("start", "months"), [ONE_MONTH_TIMEZONE, TWO_MONTH_TIMEZONE], ) -@freeze_time("2022-08-31 21:00:00-07:00") +@pytest.mark.freeze_time("2022-08-31 21:00:00-07:00") async def test_browse_media_time_timezone( hass: HomeAssistant, ufp: MockUFPFixture, @@ -713,7 +712,7 @@ async def test_browse_media_eventthumb( assert browse.media_class == MediaClass.IMAGE -@freeze_time("2022-09-15 03:00:00-07:00") +@pytest.mark.freeze_time("2022-09-15 03:00:00-07:00") async def test_browse_media_day( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ) -> None: From 31c988c4f0dac38c90ae2dcfe7c40dfbafd8d40e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 15:03:26 -1000 Subject: [PATCH 0046/1165] Fix index not being dropped on postgresql databases with a schema prefix (#90144) * Fix index not being dropped on postgresql databases with a schema prefix Added logging in case index drops fail so we can tell why in the future * coverage --- .../components/recorder/migration.py | 108 ++++++++---------- tests/components/recorder/test_migrate.py | 47 +++++++- 2 files changed, 92 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 927097b18f..931422b64f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -297,6 +297,19 @@ def _create_index( _LOGGER.debug("Finished creating %s", index_name) +def _execute_or_collect_error( + session_maker: Callable[[], Session], query: str, errors: list[str] +) -> bool: + """Execute a query or collect an error.""" + with session_scope(session=session_maker()) as session: + try: + session.connection().execute(text(query)) + return True + except SQLAlchemyError as err: + errors.append(str(err)) + return False + + def _drop_index( session_maker: Callable[[], Session], table_name: str, @@ -322,74 +335,45 @@ def _drop_index( index_name, table_name, ) - success = False + index_to_drop: str | None = None + with session_scope(session=session_maker()) as session: + index_to_drop = get_index_by_name(session, table_name, index_name) - # Engines like DB2/Oracle - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute(text(f"DROP INDEX {index_name}")) - success = True - - # Engines like SQLite, SQL Server - if not success: - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute( - text( - "DROP INDEX {table}.{index}".format( - index=index_name, table=table_name - ) - ) - ) - success = True - - if not success: - # Engines like MySQL, MS Access - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute( - text( - "DROP INDEX {index} ON {table}".format( - index=index_name, table=table_name - ) - ) - ) - success = True - - if not success: - # Engines like postgresql may have a prefix - # ex idx_16532_ix_events_event_type_time_fired - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - if index_to_drop := get_index_by_name(session, table_name, index_name): - connection.execute(text(f"DROP INDEX {index_to_drop}")) - success = True - - if success: + if index_to_drop is None: _LOGGER.debug( - "Finished dropping index %s from table %s", index_name, table_name + "The index %s on table %s no longer exists", index_name, table_name ) return - if quiet: - return + errors: list[str] = [] + for query in ( + # Engines like DB2/Oracle + f"DROP INDEX {index_name}", + # Engines like SQLite, SQL Server + f"DROP INDEX {table_name}.{index_name}", + # Engines like MySQL, MS Access + f"DROP INDEX {index_name} ON {table_name}", + # Engines like postgresql may have a prefix + # ex idx_16532_ix_events_event_type_time_fired + f"DROP INDEX {index_to_drop}", + ): + if _execute_or_collect_error(session_maker, query, errors): + _LOGGER.debug( + "Finished dropping index %s from table %s", index_name, table_name + ) + return - _LOGGER.warning( - ( - "Failed to drop index `%s` from table `%s`. Schema " - "Migration will continue; this is not a " - "critical operation" - ), - index_name, - table_name, - ) + if not quiet: + _LOGGER.warning( + ( + "Failed to drop index `%s` from table `%s`. Schema " + "Migration will continue; this is not a " + "critical operation: %s" + ), + index_name, + table_name, + errors, + ) def _add_columns( diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index b23b7a2dfc..fe4f1e016f 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -15,6 +15,7 @@ from sqlalchemy.exc import ( InternalError, OperationalError, ProgrammingError, + SQLAlchemyError, ) from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool @@ -492,7 +493,51 @@ def test_forgiving_add_index(recorder_db_url: str) -> None: with Session(engine) as session: instance = Mock() instance.get_session = Mock(return_value=session) - migration._create_index(instance.get_session, "states", "ix_states_context_id") + migration._create_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + engine.dispose() + + +def test_forgiving_drop_index( + recorder_db_url: str, caplog: pytest.LogCaptureFixture +) -> None: + """Test that drop index will continue if index drop fails.""" + engine = create_engine(recorder_db_url, poolclass=StaticPool) + db_schema.Base.metadata.create_all(engine) + with Session(engine) as session: + instance = Mock() + instance.get_session = Mock(return_value=session) + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + + with patch( + "homeassistant.components.recorder.migration.get_index_by_name", + return_value="ix_states_context_id_bin", + ), patch.object( + session, "connection", side_effect=SQLAlchemyError("connection failure") + ): + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + assert "Failed to drop index" in caplog.text + assert "connection failure" in caplog.text + caplog.clear() + with patch( + "homeassistant.components.recorder.migration.get_index_by_name", + return_value="ix_states_context_id_bin", + ), patch.object( + session, "connection", side_effect=SQLAlchemyError("connection failure") + ): + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin", quiet=True + ) + assert "Failed to drop index" not in caplog.text + assert "connection failure" not in caplog.text engine.dispose() From 8a591fa16e9fba77dfbeba1f52299e32b5ac06a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 15:17:36 -1000 Subject: [PATCH 0047/1165] Add auto repairs for events schema (#90136) * Add auto repairs for events schema * Add auto repairs for events schema * Add auto repairs for events schema * Add auto repairs for events schema * Add auto repairs for events schema * fix bug - wrong table --- .../recorder/auto_repairs/events/__init__.py | 1 + .../recorder/auto_repairs/events/schema.py | 31 ++++++++ .../components/recorder/migration.py | 6 ++ .../recorder/auto_repairs/events/__init__.py | 1 + .../auto_repairs/events/test_schema.py | 76 +++++++++++++++++++ .../recorder/auto_repairs/states/__init__.py | 4 - .../auto_repairs/statistics/__init__.py | 4 - 7 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/recorder/auto_repairs/events/__init__.py create mode 100644 homeassistant/components/recorder/auto_repairs/events/schema.py create mode 100644 tests/components/recorder/auto_repairs/events/__init__.py create mode 100644 tests/components/recorder/auto_repairs/events/test_schema.py diff --git a/homeassistant/components/recorder/auto_repairs/events/__init__.py b/homeassistant/components/recorder/auto_repairs/events/__init__.py new file mode 100644 index 0000000000..66ae1a1407 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/events/__init__.py @@ -0,0 +1 @@ +"""events repairs for Recorder.""" diff --git a/homeassistant/components/recorder/auto_repairs/events/schema.py b/homeassistant/components/recorder/auto_repairs/events/schema.py new file mode 100644 index 0000000000..e32cbd4df7 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/events/schema.py @@ -0,0 +1,31 @@ +"""Events schema repairs.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...db_schema import EventData, Events +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) + +if TYPE_CHECKING: + from ... import Recorder + + +def validate_db_schema(instance: Recorder) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + return validate_table_schema_supports_utf8( + instance, EventData, (EventData.shared_data,) + ) | validate_db_schema_precision(instance, Events) + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + correct_db_schema_utf8(instance, EventData, schema_errors) + correct_db_schema_precision(instance, Events, schema_errors) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 931422b64f..0eee065a0c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -28,6 +28,10 @@ from homeassistant.core import HomeAssistant from homeassistant.util.enum import try_parse_enum from homeassistant.util.ulid import ulid_to_bytes +from .auto_repairs.events.schema import ( + correct_db_schema as events_correct_db_schema, + validate_db_schema as events_validate_db_schema, +) from .auto_repairs.states.schema import ( correct_db_schema as states_correct_db_schema, validate_db_schema as states_validate_db_schema, @@ -199,6 +203,7 @@ def _find_schema_errors( schema_errors: set[str] = set() schema_errors |= statistics_validate_db_schema(instance) schema_errors |= states_validate_db_schema(instance) + schema_errors |= events_validate_db_schema(instance) return schema_errors @@ -250,6 +255,7 @@ def migrate_schema( ) statistics_correct_db_schema(instance, schema_errors) states_correct_db_schema(instance, schema_errors) + events_correct_db_schema(instance, schema_errors) if current_version != SCHEMA_VERSION: instance.queue_task(PostSchemaMigrationTask(current_version, SCHEMA_VERSION)) diff --git a/tests/components/recorder/auto_repairs/events/__init__.py b/tests/components/recorder/auto_repairs/events/__init__.py new file mode 100644 index 0000000000..fca6a655ba --- /dev/null +++ b/tests/components/recorder/auto_repairs/events/__init__.py @@ -0,0 +1 @@ +"""Tests for Recorder component.""" diff --git a/tests/components/recorder/auto_repairs/events/test_schema.py b/tests/components/recorder/auto_repairs/events/test_schema.py new file mode 100644 index 0000000000..b19ff4ca50 --- /dev/null +++ b/tests/components/recorder/auto_repairs/events/test_schema.py @@ -0,0 +1,76 @@ +"""The test repairing events schema.""" + +# pylint: disable=invalid-name +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.core import HomeAssistant + +from ...common import async_wait_recording_done + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +async def test_validate_db_schema_fix_float_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, +) -> None: + """Test validating DB schema with postgresql and mysql. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", + return_value={"events.double precision"}, + ), patch( + "homeassistant.components.recorder.migration._modify_columns" + ) as modify_columns_mock: + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: events.double precision" + in caplog.text + ) + modification = [ + "time_fired_ts DOUBLE PRECISION", + ] + modify_columns_mock.assert_called_once_with(ANY, ANY, "events", modification) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_utf8_issue_event_data( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"event_data.4-byte UTF-8"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: event_data.4-byte UTF-8" + in caplog.text + ) + assert ( + "Updating character set and collation of table event_data to utf8mb4" + in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/states/__init__.py b/tests/components/recorder/auto_repairs/states/__init__.py index 6e98d881ea..fca6a655ba 100644 --- a/tests/components/recorder/auto_repairs/states/__init__.py +++ b/tests/components/recorder/auto_repairs/states/__init__.py @@ -1,5 +1 @@ """Tests for Recorder component.""" - -import pytest - -pytest.register_assert_rewrite("tests.components.recorder.common") diff --git a/tests/components/recorder/auto_repairs/statistics/__init__.py b/tests/components/recorder/auto_repairs/statistics/__init__.py index 6e98d881ea..fca6a655ba 100644 --- a/tests/components/recorder/auto_repairs/statistics/__init__.py +++ b/tests/components/recorder/auto_repairs/statistics/__init__.py @@ -1,5 +1 @@ """Tests for Recorder component.""" - -import pytest - -pytest.register_assert_rewrite("tests.components.recorder.common") From ca576d45acf44530c1fe932518132f9650ad12ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 16:03:41 -1000 Subject: [PATCH 0048/1165] Cache decode of JWT tokens (#90013) --- homeassistant/auth/__init__.py | 10 +- homeassistant/auth/jwt_wrapper.py | 116 +++++++++++++++++ homeassistant/components/http/auth.py | 3 +- tests/auth/test_init.py | 173 ++++++++++++++++++++++++++ tests/auth/test_jwt_wrapper.py | 12 ++ 5 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 homeassistant/auth/jwt_wrapper.py create mode 100644 tests/auth/test_jwt_wrapper.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 5c401570de..9a53717427 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -14,7 +14,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.util import dt as dt_util -from . import auth_store, models +from . import auth_store, jwt_wrapper, models from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config @@ -555,9 +555,7 @@ class AuthManager: ) -> models.RefreshToken | None: """Return refresh token if an access token is valid.""" try: - unverif_claims = jwt.decode( - token, algorithms=["HS256"], options={"verify_signature": False} - ) + unverif_claims = jwt_wrapper.unverified_hs256_token_decode(token) except jwt.InvalidTokenError: return None @@ -573,7 +571,9 @@ class AuthManager: issuer = refresh_token.id try: - jwt.decode(token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"]) + jwt_wrapper.verify_and_decode( + token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"] + ) except jwt.InvalidTokenError: return None diff --git a/homeassistant/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py new file mode 100644 index 0000000000..546e4afdcf --- /dev/null +++ b/homeassistant/auth/jwt_wrapper.py @@ -0,0 +1,116 @@ +"""Provide a wrapper around JWT that caches decoding tokens. + +Since we decode the same tokens over and over again +we can cache the result of the decode of valid tokens +to speed up the process. +""" +from __future__ import annotations + +from datetime import timedelta +from functools import lru_cache, partial +from typing import Any + +from jwt import DecodeError, PyJWS, PyJWT + +from homeassistant.util.json import json_loads + +JWT_TOKEN_CACHE_SIZE = 16 +MAX_TOKEN_SIZE = 8192 + +_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss") + +_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | { + "require": [] +} +_NO_VERIFY_OPTIONS = {f"verify_{key}": False for key in _VERIFY_KEYS} + + +class _PyJWSWithLoadCache(PyJWS): + """PyJWS with a dedicated load implementation.""" + + @lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) + # We only ever have a global instance of this class + # so we do not have to worry about the LRU growing + # each time we create a new instance. + def _load(self, jwt: str | bytes) -> tuple[bytes, bytes, dict, bytes]: + """Load a JWS.""" + return super()._load(jwt) + + +_jws = _PyJWSWithLoadCache() + + +@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) +def _decode_payload(json_payload: str) -> dict[str, Any]: + """Decode the payload from a JWS dictionary.""" + try: + payload = json_loads(json_payload) + except ValueError as err: + raise DecodeError(f"Invalid payload string: {err}") from err + if not isinstance(payload, dict): + raise DecodeError("Invalid payload string: must be a json object") + return payload + + +class _PyJWTWithVerify(PyJWT): + """PyJWT with a fast decode implementation.""" + + def decode_payload( + self, jwt: str, key: str, options: dict[str, Any], algorithms: list[str] + ) -> dict[str, Any]: + """Decode a JWT's payload.""" + if len(jwt) > MAX_TOKEN_SIZE: + # Avoid caching impossible tokens + raise DecodeError("Token too large") + return _decode_payload( + _jws.decode_complete( + jwt=jwt, + key=key, + algorithms=algorithms, + options=options, + )["payload"] + ) + + def verify_and_decode( + self, + jwt: str, + key: str, + algorithms: list[str], + issuer: str | None = None, + leeway: int | float | timedelta = 0, + options: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Verify a JWT's signature and claims.""" + merged_options = {**_VERIFY_OPTIONS, **(options or {})} + payload = self.decode_payload( + jwt=jwt, + key=key, + options=merged_options, + algorithms=algorithms, + ) + # These should never be missing since we verify them + # but this is an additional safeguard to make sure + # nothing slips through. + assert "exp" in payload, "exp claim is required" + assert "iat" in payload, "iat claim is required" + self._validate_claims( # type: ignore[no-untyped-call] + payload=payload, + options=merged_options, + issuer=issuer, + leeway=leeway, + ) + return payload + + +_jwt = _PyJWTWithVerify() # type: ignore[no-untyped-call] +verify_and_decode = _jwt.verify_and_decode +unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)( + partial( + _jwt.decode_payload, key="", algorithms=["HS256"], options=_NO_VERIFY_OPTIONS + ) +) + +__all__ = [ + "unverified_hs256_token_decode", + "verify_and_decode", +] diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 5213cd1b07..ec8c7de989 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -13,6 +13,7 @@ from aiohttp.web import Application, Request, StreamResponse, middleware import jwt from yarl import URL +from homeassistant.auth import jwt_wrapper from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.components import websocket_api @@ -175,7 +176,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: return False try: - claims = jwt.decode( + claims = jwt_wrapper.verify_and_decode( signature, secret, algorithms=["HS256"], options={"verify_iss": False} ) except jwt.InvalidTokenError: diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 3d3674e137..83c08dd73e 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -3,6 +3,7 @@ from datetime import timedelta from typing import Any from unittest.mock import Mock, patch +from freezegun import freeze_time import jwt import pytest import voluptuous as vol @@ -1127,3 +1128,175 @@ async def test_event_user_updated_fires(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(events) == 1 + + +async def test_access_token_with_invalid_signature(mock_hass) -> None: + """Test rejecting access tokens with an invalid signature.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id + + # Now we corrupt the signature + header, payload, signature = access_token.split(".") + invalid_signature = "a" * len(signature) + invalid_token = f"{header}.{payload}.{invalid_signature}" + + assert access_token != invalid_token + + result = await manager.async_validate_access_token(invalid_token) + assert result is None + + +async def test_access_token_with_null_signature(mock_hass) -> None: + """Test rejecting access tokens with a null signature.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id + + # Now we make the signature all nulls + header, payload, signature = access_token.split(".") + invalid_signature = "\0" * len(signature) + invalid_token = f"{header}.{payload}.{invalid_signature}" + + assert access_token != invalid_token + + result = await manager.async_validate_access_token(invalid_token) + assert result is None + + +async def test_access_token_with_empty_signature(mock_hass) -> None: + """Test rejecting access tokens with an empty signature.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id + + # Now we make the signature all nulls + header, payload, _ = access_token.split(".") + invalid_token = f"{header}.{payload}." + + assert access_token != invalid_token + + result = await manager.async_validate_access_token(invalid_token) + assert result is None + + +async def test_access_token_with_empty_key(mock_hass) -> None: + """Test rejecting access tokens with an empty key.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + + access_token = manager.async_create_access_token(refresh_token) + + await manager.async_remove_refresh_token(refresh_token) + # Now remove the token from the keyring + # so we will get an empty key + + assert await manager.async_validate_access_token(access_token) is None + + +async def test_reject_access_token_with_impossible_large_size(mock_hass) -> None: + """Test rejecting access tokens with impossible sizes.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + assert await manager.async_validate_access_token("a" * 10000) is None + + +async def test_reject_token_with_invalid_json_payload(mock_hass) -> None: + """Test rejecting access tokens with invalid json payload.""" + jws = jwt.PyJWS() + token_with_invalid_json = jws.encode( + b"invalid", b"invalid", "HS256", {"alg": "HS256", "typ": "JWT"} + ) + manager = await auth.auth_manager_from_config(mock_hass, [], []) + assert await manager.async_validate_access_token(token_with_invalid_json) is None + + +async def test_reject_token_with_not_dict_json_payload(mock_hass) -> None: + """Test rejecting access tokens with not a dict json payload.""" + jws = jwt.PyJWS() + token_not_a_dict_json = jws.encode( + b'["invalid"]', b"invalid", "HS256", {"alg": "HS256", "typ": "JWT"} + ) + manager = await auth.auth_manager_from_config(mock_hass, [], []) + assert await manager.async_validate_access_token(token_not_a_dict_json) is None + + +async def test_access_token_that_expires_soon(mock_hass) -> None: + """Test access token from refresh token that expires very soon.""" + now = dt_util.utcnow() + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Token that expires very soon", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(seconds=1), + ) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id + + with freeze_time(now + timedelta(minutes=1)): + assert await manager.async_validate_access_token(access_token) is None + + +async def test_access_token_from_the_future(mock_hass) -> None: + """Test we reject an access token from the future.""" + now = dt_util.utcnow() + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + with freeze_time(now + timedelta(days=365)): + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Token that expires very soon", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=10), + ) + assert ( + refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + ) + access_token = manager.async_create_access_token(refresh_token) + + assert await manager.async_validate_access_token(access_token) is None + + with freeze_time(now + timedelta(days=365)): + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id diff --git a/tests/auth/test_jwt_wrapper.py b/tests/auth/test_jwt_wrapper.py new file mode 100644 index 0000000000..297d4dd5d7 --- /dev/null +++ b/tests/auth/test_jwt_wrapper.py @@ -0,0 +1,12 @@ +"""Tests for the Home Assistant auth jwt_wrapper module.""" + +import jwt +import pytest + +from homeassistant.auth import jwt_wrapper + + +async def test_reject_access_token_with_impossible_large_size() -> None: + """Test rejecting access tokens with impossible sizes.""" + with pytest.raises(jwt.DecodeError): + jwt_wrapper.unverified_hs256_token_decode("a" * 10000) From dcc52bd366b9a1f136f05ef22cd840443ff50335 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 16:10:47 -1000 Subject: [PATCH 0049/1165] Bump PySwitchbot to 0.37.4 (#90146) fixes #90090 fixes #89061 changelog: https://github.com/Danielhiversen/pySwitchbot/compare/0.37.3...0.37.4 --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 2637f578b8..ada24bcee5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -40,5 +40,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.37.3"] + "requirements": ["PySwitchbot==0.37.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 42355c1073..23ae790f36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,7 +40,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.3 +PySwitchbot==0.37.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9c4ae8876..dfd6517954 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.3 +PySwitchbot==0.37.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 From 12352b2ce1ca27eb3ecbb54afa2d64e0c10d8d44 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 22:54:09 -0400 Subject: [PATCH 0050/1165] Always enforce URL param ordering for signed URLs (#90148) Always enforce URL param ordering --- homeassistant/components/http/auth.py | 15 +++++++------- tests/components/http/test_auth.py | 28 ++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index ec8c7de989..f2cfe06740 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -61,9 +61,7 @@ def async_sign_path( url = URL(path) now = dt_util.utcnow() - params = dict(sorted(url.query.items())) - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [itm for itm in url.query.items() if itm[0] not in SAFE_QUERY_PARAMS] encoded = jwt.encode( { "iss": refresh_token_id, @@ -76,7 +74,7 @@ def async_sign_path( algorithm="HS256", ) - params[SIGN_QUERY_PARAM] = encoded + params.append((SIGN_QUERY_PARAM, encoded)) url = url.with_query(params) return f"{url.path}?{url.query_string}" @@ -185,10 +183,11 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["path"] != request.path: return False - params = dict(sorted(request.query.items())) - del params[SIGN_QUERY_PARAM] - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [ + list(itm) # claims stores tuples as lists + for itm in request.query.items() + if itm[0] not in SAFE_QUERY_PARAMS and itm[0] != SIGN_QUERY_PARAM + ] if claims["params"] != params: return False diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index fb00640cdc..246572e64f 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -352,6 +352,12 @@ async def test_auth_access_signed_path_with_query_param( data = await req.json() assert data["user_id"] == refresh_token.user.id + # Without query params not allowed + url = yarl.URL(signed_path) + signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}" + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + async def test_auth_access_signed_path_with_query_param_order( hass: HomeAssistant, @@ -374,12 +380,24 @@ async def test_auth_access_signed_path_with_query_param_order( refresh_token_id=refresh_token.id, ) url = yarl.URL(signed_path) - signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" - req = await client.get(signed_path) - assert req.status == HTTPStatus.OK - data = await req.json() - assert data["user_id"] == refresh_token.user.id + # Change order + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" + ) + assert req.status == HTTPStatus.UNAUTHORIZED + + # Duplicate a param + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&test=test&foo=aaa&foo=bar" + ) + assert req.status == HTTPStatus.UNAUTHORIZED + + # Remove a param + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&test=test" + ) + assert req.status == HTTPStatus.UNAUTHORIZED async def test_auth_access_signed_path_with_query_param_safe_param( From a5aa5c0c012b73a454948a4faa9174ff40488367 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Mar 2023 04:35:45 +0100 Subject: [PATCH 0051/1165] Fix imap_email_content unknown status and replaying stale states (#89563) --- .../components/imap_email_content/sensor.py | 74 ++++++++++++------- .../imap_email_content/test_sensor.py | 26 +++++-- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index b14de63268..53cb921860 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -95,9 +95,25 @@ class EmailReader: self._folder = folder self._verify_ssl = verify_ssl self._last_id = None + self._last_message = None self._unread_ids = deque([]) self.connection = None + @property + def last_id(self) -> int | None: + """Return last email uid that was processed.""" + return self._last_id + + @property + def last_unread_id(self) -> int | None: + """Return last email uid received.""" + # We assume the last id in the list is the last unread id + # We cannot know if that is the newest one, because it could arrive later + # https://stackoverflow.com/questions/12409862/python-imap-the-order-of-uids + if self._unread_ids: + return int(self._unread_ids[-1]) + return self._last_id + def connect(self): """Login and setup the connection.""" ssl_context = client_context() if self._verify_ssl else None @@ -128,21 +144,21 @@ class EmailReader: try: self.connection.select(self._folder, readonly=True) - if not self._unread_ids: - search = f"SINCE {datetime.date.today():%d-%b-%Y}" - if self._last_id is not None: - search = f"UID {self._last_id}:*" - - _, data = self.connection.uid("search", None, search) - self._unread_ids = deque(data[0].split()) + if self._last_id is None: + # search for today and yesterday + time_from = datetime.datetime.now() - datetime.timedelta(days=1) + search = f"SINCE {time_from:%d-%b-%Y}" + else: + search = f"UID {self._last_id}:*" + _, data = self.connection.uid("search", None, search) + self._unread_ids = deque(data[0].split()) while self._unread_ids: message_uid = self._unread_ids.popleft() if self._last_id is None or int(message_uid) > self._last_id: self._last_id = int(message_uid) - return self._fetch_message(message_uid) - - return self._fetch_message(str(self._last_id)) + self._last_message = self._fetch_message(message_uid) + return self._last_message except imaplib.IMAP4.error: _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) @@ -254,22 +270,30 @@ class EmailContentSensor(SensorEntity): def update(self) -> None: """Read emails and publish state change.""" email_message = self._email_reader.read_next() + while ( + self._last_id is None or self._last_id != self._email_reader.last_unread_id + ): + if email_message is None: + self._message = None + self._state_attributes = {} + return - if email_message is None: - self._message = None - self._state_attributes = {} - return + self._last_id = self._email_reader.last_id - if self.sender_allowed(email_message): - message = EmailContentSensor.get_msg_subject(email_message) + if self.sender_allowed(email_message): + message = EmailContentSensor.get_msg_subject(email_message) - if self._value_template is not None: - message = self.render_template(email_message) + if self._value_template is not None: + message = self.render_template(email_message) - self._message = message - self._state_attributes = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - } + self._message = message + self._state_attributes = { + ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), + ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), + ATTR_DATE: email_message["Date"], + ATTR_BODY: EmailContentSensor.get_msg_text(email_message), + } + + if self._last_id == self._email_reader.last_unread_id: + break + email_message = self._email_reader.read_next() diff --git a/tests/components/imap_email_content/test_sensor.py b/tests/components/imap_email_content/test_sensor.py index afa6116ff4..ba2b362af7 100644 --- a/tests/components/imap_email_content/test_sensor.py +++ b/tests/components/imap_email_content/test_sensor.py @@ -14,9 +14,16 @@ from homeassistant.helpers.template import Template class FakeEMailReader: """A test class for sending test emails.""" - def __init__(self, messages): + def __init__(self, messages) -> None: """Set up the fake email reader.""" self._messages = messages + self.last_id = 0 + self.last_unread_id = len(messages) + + def add_test_message(self, message): + """Add a new message.""" + self.last_unread_id += 1 + self._messages.append(message) def connect(self): """Stay always Connected.""" @@ -26,6 +33,7 @@ class FakeEMailReader: """Get the next email.""" if len(self._messages) == 0: return None + self.last_id += 1 return self._messages.popleft() @@ -146,7 +154,7 @@ async def test_multi_part_only_other_text(hass: HomeAssistant) -> None: async def test_multiple_emails(hass: HomeAssistant) -> None: - """Test multiple emails.""" + """Test multiple emails, discarding stale states.""" states = [] test_message1 = email.message.Message() @@ -158,9 +166,15 @@ async def test_multiple_emails(hass: HomeAssistant) -> None: test_message2 = email.message.Message() test_message2["From"] = "sender@test.com" test_message2["Subject"] = "Test 2" - test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) + test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 58) test_message2.set_payload("Test Message 2") + test_message3 = email.message.Message() + test_message3["From"] = "sender@test.com" + test_message3["Subject"] = "Test 3" + test_message3["Date"] = datetime.datetime(2016, 1, 1, 12, 50, 1) + test_message3.set_payload("Test Message 2") + def state_changed_listener(entity_id, from_s, to_s): states.append(to_s) @@ -178,11 +192,13 @@ async def test_multiple_emails(hass: HomeAssistant) -> None: sensor.async_schedule_update_ha_state(True) await hass.async_block_till_done() + # Fake a new received message + sensor._email_reader.add_test_message(test_message3) sensor.async_schedule_update_ha_state(True) await hass.async_block_till_done() - assert states[0].state == "Test" - assert states[1].state == "Test 2" + assert states[0].state == "Test 2" + assert states[1].state == "Test 3" assert sensor.extra_state_attributes["body"] == "Test Message 2" From 1e03ff68a2604b78db53092cabd30f47b753c753 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 16:44:49 -1000 Subject: [PATCH 0052/1165] Bump aioharmony to 0.2.10 (#89831) fixes #89823 --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 2603ee613a..c6a6327046 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -13,7 +13,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.2.9"], + "requirements": ["aioharmony==0.2.10"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/requirements_all.txt b/requirements_all.txt index cdffe860f7..3fcf96ccb5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -171,7 +171,7 @@ aiogithubapi==22.10.1 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.9 +aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==2.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8f2b57a76..7061c3bf62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -155,7 +155,7 @@ aiogithubapi==22.10.1 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.9 +aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==2.6.1 From 713d3025f20b8649d823804bd669b778723f72ea Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 20 Mar 2023 12:08:27 +0100 Subject: [PATCH 0053/1165] Correct missing wordswap for S series nibe (#89866) Correct missing wordswap for nibe --- homeassistant/components/nibe_heatpump/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index fd77b5e234..89aac6bed6 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -62,13 +62,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nibe Heat Pump from a config entry.""" heatpump = HeatPump(Model[entry.data[CONF_MODEL]]) + heatpump.word_swap = entry.data.get(CONF_WORD_SWAP, True) await heatpump.initialize() connection: Connection connection_type = entry.data[CONF_CONNECTION_TYPE] if connection_type == CONF_CONNECTION_TYPE_NIBEGW: - heatpump.word_swap = entry.data[CONF_WORD_SWAP] connection = NibeGW( heatpump, entry.data[CONF_IP_ADDRESS], From de6f55dcfb994929753d419bedee5056676d7f7d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Mar 2023 01:57:40 +0100 Subject: [PATCH 0054/1165] Fix blocking MQTT entry unload (#89922) * Remove unneeded async_block_till_done * use await asyncio.sleep(0) instead --- homeassistant/components/mqtt/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index a1b194284c..ff126a2260 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -706,7 +706,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for component in PLATFORMS ) ) - await hass.async_block_till_done() + await asyncio.sleep(0) # Unsubscribe reload dispatchers while reload_dispatchers := mqtt_data.reload_dispatchers: reload_dispatchers.pop()() From 127f2289a1af45ac123e733947f11b7ba40df3f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Mar 2023 20:59:05 -1000 Subject: [PATCH 0055/1165] Remove async_block_till_done in freebox (#89928) async_block_till_done() is not meant to be called in integrations --- homeassistant/components/freebox/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index fd9252aaa1..dbee01c4e7 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -77,7 +77,6 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Check permissions await fbx.system.get_config() await fbx.lan.get_hosts_list() - await self.hass.async_block_till_done() # Close connection await fbx.close() From 92fb978a0377e6cea5c848c6a7265ab2f62a5657 Mon Sep 17 00:00:00 2001 From: micha91 Date: Mon, 20 Mar 2023 11:59:27 +0100 Subject: [PATCH 0056/1165] Bump aiomusiccast to 0.14.8 (#89978) --- homeassistant/components/yamaha_musiccast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 9a19f61eb4..48b8de2060 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "iot_class": "local_push", "loggers": ["aiomusiccast"], - "requirements": ["aiomusiccast==0.14.7"], + "requirements": ["aiomusiccast==0.14.8"], "ssdp": [ { "manufacturer": "Yamaha Corporation" diff --git a/requirements_all.txt b/requirements_all.txt index 3fcf96ccb5..27eb15772e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -214,7 +214,7 @@ aiolyric==1.0.9 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.7 +aiomusiccast==0.14.8 # homeassistant.components.nanoleaf aionanoleaf==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7061c3bf62..2168b2b42f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -195,7 +195,7 @@ aiolyric==1.0.9 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.7 +aiomusiccast==0.14.8 # homeassistant.components.nanoleaf aionanoleaf==0.2.1 From 1f71068740a6947acaa485af86b2acb2bad36b46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 17:49:59 -1000 Subject: [PATCH 0057/1165] Handle cancelation of wait_for_ble_connections_free in esphome bluetooth (#90014) Handle cancelation in wait_for_ble_connections_free If `wait_for_ble_connections_free` was canceled due to timeout or the esp disconnecting from Home Assistant the future would get canceled. When we reconnect and get the next callback we need to handle it being done. fixes ``` 2023-03-21 02:34:36.876 ERROR (MainThread) [homeassistant] Error doing job: Fatal error: protocol.data_received() call failed. Traceback (most recent call last): File "/usr/local/lib/python3.10/asyncio/selector_events.py", line 868, in _read_ready__data_received self._protocol.data_received(data) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/_frame_helper.py", line 195, in data_received self._callback_packet(msg_type_int, bytes(packet_data)) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/_frame_helper.py", line 110, in _callback_packet self._on_pkt(Packet(type_, data)) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/connection.py", line 688, in _process_packet handler(msg) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/client.py", line 482, in on_msg on_bluetooth_connections_free_update(resp.free, resp.limit) File "/usr/src/homeassistant/homeassistant/components/esphome/entry_data.py", line 136, in async_update_ble_connection_limits fut.set_result(free) asyncio.exceptions.InvalidStateError: invalid state ``` --- homeassistant/components/esphome/entry_data.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 0aed6ce43a..a389d74643 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -130,10 +130,15 @@ class RuntimeEntryData: ) self.ble_connections_free = free self.ble_connections_limit = limit - if free: - for fut in self._ble_connection_free_futures: + if not free: + return + for fut in self._ble_connection_free_futures: + # If wait_for_ble_connections_free gets cancelled, it will + # leave a future in the list. We need to check if it's done + # before setting the result. + if not fut.done(): fut.set_result(free) - self._ble_connection_free_futures.clear() + self._ble_connection_free_futures.clear() async def wait_for_ble_connections_free(self) -> int: """Wait until there are free BLE connections.""" From 2a18261efb4b476b38e774740d59d04f09f9c8fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 17:47:26 -1000 Subject: [PATCH 0058/1165] Bump yalexs_ble to 2.1.0 (#89772) switches to using cryptography to reduce the number of deps changelog: https://github.com/bdraco/yalexs-ble/compare/v2.0.4...v2.1.0 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index dedfc9127a..eba6e2c1b3 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs_ble==2.0.4"] + "requirements": ["yalexs==1.2.7", "yalexs_ble==2.1.0"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index e34ace05e1..e793fe2728 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.0.4"] + "requirements": ["yalexs-ble==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 27eb15772e..debc7d480c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2670,13 +2670,13 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.4 +yalexs-ble==2.1.0 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.4 +yalexs_ble==2.1.0 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2168b2b42f..09afa5970b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1895,13 +1895,13 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.4 +yalexs-ble==2.1.0 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.4 +yalexs_ble==2.1.0 # homeassistant.components.yeelight yeelight==0.7.10 From 53d400ca9679650f24bafac61f3e3fa8f2102fc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 17:49:30 -1000 Subject: [PATCH 0059/1165] Bump yalexs-ble to 2.1.1 (#90015) * Bump yalexs-ble to 2.1.1 There was another task that could be prematurely GCed changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.0...v2.1.1 * fixes --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 6 ++---- requirements_test_all.txt | 6 ++---- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index eba6e2c1b3..213f0237e1 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs_ble==2.1.0"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.1"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index e793fe2728..6bb58752a0 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.0"] + "requirements": ["yalexs-ble==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index debc7d480c..e763e3037a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2669,15 +2669,13 @@ xs1-api-client==3.0.0 # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.3.9 +# homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.0 +yalexs-ble==2.1.1 # homeassistant.components.august yalexs==1.2.7 -# homeassistant.components.august -yalexs_ble==2.1.0 - # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09afa5970b..73feda7628 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1894,15 +1894,13 @@ xmltodict==0.13.0 # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.3.9 +# homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.0 +yalexs-ble==2.1.1 # homeassistant.components.august yalexs==1.2.7 -# homeassistant.components.august -yalexs_ble==2.1.0 - # homeassistant.components.yeelight yeelight==0.7.10 From 3747fd5dcb74842b6eab31630238bceccc54fc0d Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 13 Mar 2023 15:00:50 +0100 Subject: [PATCH 0060/1165] Bump easyEnergy to v0.2.1 (#89630) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6b88dd84c8..fc0a4fd773 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.1.2"] + "requirements": ["easyenergy==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e763e3037a..ba96fabc1a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.1.2 +easyenergy==0.2.1 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73feda7628..0331bdf76e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -490,7 +490,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.1.2 +easyenergy==0.2.1 # homeassistant.components.elgato elgato==4.0.1 From 146347e31aba16345f2ecc51496601e80d24d7c1 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 22 Mar 2023 10:46:17 +0100 Subject: [PATCH 0061/1165] Bump easyEnergy to v0.2.2 (#90080) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index fc0a4fd773..0954269628 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.2.1"] + "requirements": ["easyenergy==0.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ba96fabc1a..f282e59eeb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.1 +easyenergy==0.2.2 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0331bdf76e..6200c913d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -490,7 +490,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.1 +easyenergy==0.2.2 # homeassistant.components.elgato elgato==4.0.1 From 406e92511bb489cf8dbd80e7c37090a2721ab942 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 21 Mar 2023 23:09:38 -0400 Subject: [PATCH 0062/1165] Bump to oralb-ble 0.17.6 (#90081) --- homeassistant/components/oralb/manifest.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index 37b043e543..a1071cc0a1 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -11,5 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/oralb", "iot_class": "local_push", - "requirements": ["oralb-ble==0.17.5"] + "loggers": ["oralb-ble"], + "requirements": ["oralb-ble==0.17.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index f282e59eeb..fe86666d9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ openwrt-luci-rpc==1.1.11 openwrt-ubus-rpc==0.0.2 # homeassistant.components.oralb -oralb-ble==0.17.5 +oralb-ble==0.17.6 # homeassistant.components.oru oru==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6200c913d3..87663416f0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ openai==0.26.2 openerz-api==0.2.0 # homeassistant.components.oralb -oralb-ble==0.17.5 +oralb-ble==0.17.6 # homeassistant.components.ovo_energy ovoenergy==1.2.0 From a7b5a0297ef913ab305b22d2ebaf0cbdf0f0c4b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 16:10:47 -1000 Subject: [PATCH 0063/1165] Bump PySwitchbot to 0.37.4 (#90146) fixes #90090 fixes #89061 changelog: https://github.com/Danielhiversen/pySwitchbot/compare/0.37.3...0.37.4 --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 2637f578b8..ada24bcee5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -40,5 +40,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.37.3"] + "requirements": ["PySwitchbot==0.37.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index fe86666d9c..9b63f54a60 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,7 +40,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.3 +PySwitchbot==0.37.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87663416f0..e71f661b4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.3 +PySwitchbot==0.37.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 From 174342860b828040b7018210861d98d1ae503de9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 22:54:09 -0400 Subject: [PATCH 0064/1165] Always enforce URL param ordering for signed URLs (#90148) Always enforce URL param ordering --- homeassistant/components/http/auth.py | 15 +++++++------- tests/components/http/test_auth.py | 28 ++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 5213cd1b07..d10bd677e4 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -60,9 +60,7 @@ def async_sign_path( url = URL(path) now = dt_util.utcnow() - params = dict(sorted(url.query.items())) - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [itm for itm in url.query.items() if itm[0] not in SAFE_QUERY_PARAMS] encoded = jwt.encode( { "iss": refresh_token_id, @@ -75,7 +73,7 @@ def async_sign_path( algorithm="HS256", ) - params[SIGN_QUERY_PARAM] = encoded + params.append((SIGN_QUERY_PARAM, encoded)) url = url.with_query(params) return f"{url.path}?{url.query_string}" @@ -184,10 +182,11 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["path"] != request.path: return False - params = dict(sorted(request.query.items())) - del params[SIGN_QUERY_PARAM] - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [ + list(itm) # claims stores tuples as lists + for itm in request.query.items() + if itm[0] not in SAFE_QUERY_PARAMS and itm[0] != SIGN_QUERY_PARAM + ] if claims["params"] != params: return False diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index fb00640cdc..246572e64f 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -352,6 +352,12 @@ async def test_auth_access_signed_path_with_query_param( data = await req.json() assert data["user_id"] == refresh_token.user.id + # Without query params not allowed + url = yarl.URL(signed_path) + signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}" + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + async def test_auth_access_signed_path_with_query_param_order( hass: HomeAssistant, @@ -374,12 +380,24 @@ async def test_auth_access_signed_path_with_query_param_order( refresh_token_id=refresh_token.id, ) url = yarl.URL(signed_path) - signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" - req = await client.get(signed_path) - assert req.status == HTTPStatus.OK - data = await req.json() - assert data["user_id"] == refresh_token.user.id + # Change order + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" + ) + assert req.status == HTTPStatus.UNAUTHORIZED + + # Duplicate a param + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&test=test&foo=aaa&foo=bar" + ) + assert req.status == HTTPStatus.UNAUTHORIZED + + # Remove a param + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&test=test" + ) + assert req.status == HTTPStatus.UNAUTHORIZED async def test_auth_access_signed_path_with_query_param_safe_param( From 117113cdfc4c81f6df6a1ef71d53ac3e58ade025 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 22:59:47 -0400 Subject: [PATCH 0065/1165] Bumped version to 2023.3.6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f8e7859bf4..ed959488dd 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "5" +PATCH_VERSION: Final = "6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index a9c70089c7..6702915a85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.5" +version = "2023.3.6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 98787383210ffb3ba655d2f957349ae4721e0393 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 18:14:54 -1000 Subject: [PATCH 0066/1165] Use rel_url for looking up frontend panels (#90149) * Use rel_url for looking up frontend panels request.url builds a new URL every time where-as rel_url is always available https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.BaseRequest.rel_url * Use rel_url for looking up frontend panels request.url builds a new URL every time where-as rel_url is always available https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.BaseRequest.rel_url --- homeassistant/components/frontend/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index b152b2d65d..8c04e59196 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -530,8 +530,9 @@ class IndexView(web_urldispatcher.AbstractResource): """ if ( request.path != "/" - and len(request.url.parts) > 1 - and request.url.parts[1] not in self.hass.data[DATA_PANELS] + and (parts := request.rel_url.parts) + and len(parts) > 1 + and parts[1] not in self.hass.data[DATA_PANELS] ): return None, set() From 1e64a55a1a8d14ffc332e72d37158dc1b8239a04 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 08:08:52 +0100 Subject: [PATCH 0067/1165] Add missing translation for invalid imap folder (#90154) Add missing translation for invalid folder --- homeassistant/components/imap/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 2fedef55f6..bb03f82bb7 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -24,6 +24,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_charset": "The specified charset is not supported", + "invalid_folder": "The selected folder is invalid", "invalid_search": "The selected search is invalid" }, "abort": { From b1519236195ea54c5aece60bb65935d4f61b4106 Mon Sep 17 00:00:00 2001 From: solazs Date: Thu, 23 Mar 2023 08:56:47 +0100 Subject: [PATCH 0068/1165] Add health mode to gree integration (#89764) Add health mode to gree integration. --- homeassistant/components/gree/switch.py | 37 +++++++++++++++++++++++++ tests/components/gree/test_switch.py | 35 +++++++++++++++++++---- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 62189fdde0..ffef6b08a9 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -26,6 +26,7 @@ async def async_setup_entry( async_add_entities( [ GreePanelLightSwitchEntity(coordinator), + GreeHealthModeSwitchEntity(coordinator), GreeQuietModeSwitchEntity(coordinator), GreeFreshAirSwitchEntity(coordinator), GreeXFanSwitchEntity(coordinator), @@ -75,6 +76,42 @@ class GreePanelLightSwitchEntity(GreeEntity, SwitchEntity): self.async_write_ha_state() +class GreeHealthModeSwitchEntity(GreeEntity, SwitchEntity): + """Representation of the health mode on the device.""" + + def __init__(self, coordinator): + """Initialize the Gree device.""" + super().__init__(coordinator, "Health mode") + self._attr_entity_registry_enabled_default = False + + @property + def icon(self) -> str | None: + """Return the icon for the device.""" + return "mdi:pine-tree" + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool: + """Return if the health mode is turned on.""" + return self.coordinator.device.anion + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + self.coordinator.device.anion = True + await self.coordinator.push_state_update() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + self.coordinator.device.anion = False + await self.coordinator.push_state_update() + self.async_write_ha_state() + + class GreeQuietModeSwitchEntity(GreeEntity, SwitchEntity): """Representation of the quiet mode state of the device.""" diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index 75af20d37e..85b9a41caf 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -14,11 +14,13 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry ENTITY_ID_LIGHT_PANEL = f"{DOMAIN}.fake_device_1_panel_light" +ENTITY_ID_HEALTH_MODE = f"{DOMAIN}.fake_device_1_health_mode" ENTITY_ID_QUIET = f"{DOMAIN}.fake_device_1_quiet" ENTITY_ID_FRESH_AIR = f"{DOMAIN}.fake_device_1_fresh_air" ENTITY_ID_XFAN = f"{DOMAIN}.fake_device_1_xfan" @@ -31,16 +33,29 @@ async def async_setup_gree(hass): await hass.async_block_till_done() +async def test_health_mode_disabled_by_default(hass): + """Test for making sure health mode is disabled on first load.""" + await async_setup_gree(hass) + + assert ( + er.async_get(hass).async_get(ENTITY_ID_HEALTH_MODE).disabled_by + == er.RegistryEntryDisabler.INTEGRATION + ) + + @pytest.mark.parametrize( "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) -async def test_send_switch_on(hass: HomeAssistant, entity) -> None: +async def test_send_switch_on( + hass: HomeAssistant, entity, entity_registry_enabled_by_default +) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -60,13 +75,14 @@ async def test_send_switch_on(hass: HomeAssistant, entity) -> None: "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) async def test_send_switch_on_device_timeout( - hass: HomeAssistant, device, entity + hass: HomeAssistant, device, entity, entity_registry_enabled_by_default ) -> None: """Test for sending power on command to the device with a device timeout.""" device().push_state_update.side_effect = DeviceTimeoutError @@ -89,12 +105,15 @@ async def test_send_switch_on_device_timeout( "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) -async def test_send_switch_off(hass: HomeAssistant, entity) -> None: +async def test_send_switch_off( + hass: HomeAssistant, entity, entity_registry_enabled_by_default +) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -114,12 +133,15 @@ async def test_send_switch_off(hass: HomeAssistant, entity) -> None: "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) -async def test_send_switch_toggle(hass: HomeAssistant, entity) -> None: +async def test_send_switch_toggle( + hass: HomeAssistant, entity, entity_registry_enabled_by_default +) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -164,12 +186,15 @@ async def test_send_switch_toggle(hass: HomeAssistant, entity) -> None: ("entity", "name"), [ (ENTITY_ID_LIGHT_PANEL, "Panel Light"), + (ENTITY_ID_HEALTH_MODE, "Health mode"), (ENTITY_ID_QUIET, "Quiet"), (ENTITY_ID_FRESH_AIR, "Fresh Air"), (ENTITY_ID_XFAN, "XFan"), ], ) -async def test_entity_name(hass: HomeAssistant, entity, name) -> None: +async def test_entity_name( + hass: HomeAssistant, entity, name, entity_registry_enabled_by_default +) -> None: """Test for name property.""" await async_setup_gree(hass) state = hass.states.get(entity) From 6739542a5d72496b27740492a6f8b3c21914ceee Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Mar 2023 09:18:35 +0100 Subject: [PATCH 0069/1165] Simplify some multi pan code (#90135) * Simplify some multi pan code * Adjust ZHA config flow --- homeassistant/components/hassio/__init__.py | 5 ++++ .../silabs_multiprotocol_addon.py | 24 +++++++++---------- .../homeassistant_sky_connect/__init__.py | 7 +++--- .../homeassistant_yellow/__init__.py | 8 +++---- homeassistant/components/zha/config_flow.py | 2 +- tests/components/hassio/test_init.py | 10 ++++++++ 6 files changed, 34 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 25482ddde9..d5449cf927 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -244,6 +244,11 @@ HARDWARE_INTEGRATIONS = { } +def hostname_from_addon_slug(addon_slug: str) -> str: + """Return hostname of add-on.""" + return addon_slug.replace("_", "-") + + @callback @bind_hass def get_info(hass: HomeAssistant) -> dict[str, Any] | None: diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index bba6b447c7..41f16462cd 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -15,6 +15,7 @@ from homeassistant.components.hassio import ( AddonInfo, AddonManager, AddonState, + hostname_from_addon_slug, is_hassio, ) from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN @@ -64,12 +65,13 @@ class SerialPortSettings: flow_control: bool -def get_zigbee_socket(hass: HomeAssistant, addon_info: AddonInfo) -> str: +def get_zigbee_socket() -> str: """Return the zigbee socket. Raises AddonError on error """ - return f"socket://{addon_info.hostname}:9999" + hostname = hostname_from_addon_slug(SILABS_MULTIPROTOCOL_ADDON_SLUG) + return f"socket://{hostname}:9999" class BaseMultiPanFlow(FlowHandler, ABC): @@ -290,7 +292,7 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): "new_discovery_info": { "name": self._zha_name(), "port": { - "path": get_zigbee_socket(self.hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", }, @@ -386,24 +388,22 @@ async def check_multi_pan_addon(hass: HomeAssistant) -> None: raise HomeAssistantError -async def get_multi_pan_addon_info( - hass: HomeAssistant, device_path: str -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is using the given device. +async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) -> bool: + """Return True if the multi-PAN addon is using the given device. - Returns None if Hass.io is not loaded, the addon is not running or the addon is + Returns False if Hass.io is not loaded, the addon is not running or the addon is connected to another device. """ if not is_hassio(hass): - return None + return False addon_manager: AddonManager = get_addon_manager(hass) addon_info: AddonInfo = await addon_manager.async_get_addon_info() if addon_info.state != AddonState.RUNNING: - return None + return False if addon_info.options["device"] != device_path: - return None + return False - return addon_info + return True diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 54c11fd379..0f7ec70471 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -4,8 +4,8 @@ from __future__ import annotations from homeassistant.components import usb from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( check_multi_pan_addon, - get_multi_pan_addon_info, get_zigbee_socket, + multi_pan_addon_using_device, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -33,9 +33,8 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: usb_dev = entry.data["device"] dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) - addon_info = await get_multi_pan_addon_info(hass, dev_path) - if not addon_info: + if not await multi_pan_addon_using_device(hass, dev_path): usb_info = get_usb_service_info(entry) await hass.config_entries.flow.async_init( "zha", @@ -47,7 +46,7 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: hw_discovery_data = { "name": "SkyConnect Multi-PAN", "port": { - "path": get_zigbee_socket(hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", } diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 72df6a5707..30015d1bae 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -4,8 +4,8 @@ from __future__ import annotations from homeassistant.components.hassio import get_os_info from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( check_multi_pan_addon, - get_multi_pan_addon_info, get_zigbee_socket, + multi_pan_addon_using_device, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -31,15 +31,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except HomeAssistantError as err: raise ConfigEntryNotReady from err - addon_info = await get_multi_pan_addon_info(hass, RADIO_DEVICE) - - if not addon_info: + if not await multi_pan_addon_using_device(hass, RADIO_DEVICE): hw_discovery_data = ZHA_HW_DISCOVERY_DATA else: hw_discovery_data = { "name": "Yellow Multi-PAN", "port": { - "path": get_zigbee_socket(hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", } diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 05dc67314e..53c4e33881 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -101,7 +101,7 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: addon_port = ListPortInfo( - device=silabs_multiprotocol_addon.get_zigbee_socket(hass, addon_info), + device=silabs_multiprotocol_addon.get_zigbee_socket(), skip_link_detection=True, ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index ead65d8129..1d86699d09 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.hassio import ( DOMAIN, STORAGE_KEY, async_get_addon_store_info, + hostname_from_addon_slug, ) from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -871,3 +872,12 @@ async def test_get_store_addon_info( data = await async_get_addon_store_info(hass, "test") assert data["name"] == "bla" assert aioclient_mock.call_count == 1 + + +def test_hostname_from_addon_slug() -> None: + """Test hostname_from_addon_slug.""" + assert hostname_from_addon_slug("mqtt") == "mqtt" + assert ( + hostname_from_addon_slug("core_silabs_multiprotocol") + == "core-silabs-multiprotocol" + ) From 1a2fa51ac99bcf3abb5eba7fa2a170cef7280e05 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:01:01 +0100 Subject: [PATCH 0070/1165] Bump bimmer_connected to 0.13.0 (#90127) --- .../bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bmw_connected_drive/__init__.py | 51 +++++- .../diagnostics/diagnostics_config_entry.json | 154 +++++++++++++++++- .../diagnostics/diagnostics_device.json | 154 +++++++++++++++++- ...x-crccs_v2_vehicles_WBY00000000REXI01.json | 60 +++++++ .../bmw-eadrax-vcs_v4_vehicles.json} | 0 ..._v4_vehicles_state_WBY00000000REXI01.json} | 0 9 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json rename tests/components/bmw_connected_drive/fixtures/vehicles/{I01/vehicles_v2_bmw_0.json => I01_REX/bmw-eadrax-vcs_v4_vehicles.json} (100%) rename tests/components/bmw_connected_drive/fixtures/vehicles/{I01/state_WBY00000000REXI01_0.json => I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json} (100%) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index cafaced522..f1768d5a0c 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer_connected==0.12.1"] + "requirements": ["bimmer_connected==0.13.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 23ae790f36..a3ddeac5db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ beautifulsoup4==4.11.1 bellows==0.34.10 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.12.1 +bimmer_connected==0.13.0 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dfd6517954..5237078f5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -358,7 +358,7 @@ beautifulsoup4==4.11.1 bellows==0.34.10 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.12.1 +bimmer_connected==0.13.0 # homeassistant.components.bluetooth bleak-retry-connector==3.0.1 diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index ed31b4308b..12957db5ca 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -1,10 +1,13 @@ """Tests for the for the BMW Connected Drive integration.""" -import json from pathlib import Path from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.const import VEHICLE_STATE_URL, VEHICLES_URL +from bimmer_connected.const import ( + VEHICLE_CHARGING_DETAILS_URL, + VEHICLE_STATE_URL, + VEHICLES_URL, +) import httpx import respx @@ -17,7 +20,12 @@ from homeassistant.components.bmw_connected_drive.const import ( from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, get_fixture_path, load_fixture +from tests.common import ( + MockConfigEntry, + get_fixture_path, + load_json_array_fixture, + load_json_object_fixture, +) FIXTURE_USER_INPUT = { CONF_USERNAME: "user@domain.com", @@ -42,6 +50,17 @@ FIXTURE_CONFIG_ENTRY = { } FIXTURE_PATH = Path(get_fixture_path("", integration=BMW_DOMAIN)) +FIXTURE_FILES = { + "vehicles": sorted(FIXTURE_PATH.rglob("*-eadrax-vcs_v4_vehicles.json")), + "states": { + p.stem.split("_")[-1]: p + for p in FIXTURE_PATH.rglob("*-eadrax-vcs_v4_vehicles_state_*.json") + }, + "charging": { + p.stem.split("_")[-1]: p + for p in FIXTURE_PATH.rglob("*-eadrax-crccs_v2_vehicles_*.json") + }, +} def vehicles_sideeffect(request: httpx.Request) -> httpx.Response: @@ -49,17 +68,31 @@ def vehicles_sideeffect(request: httpx.Request) -> httpx.Response: x_user_agent = request.headers.get("x-user-agent", "").split(";") brand = x_user_agent[1] vehicles = [] - for vehicle_file in FIXTURE_PATH.rglob(f"vehicles_v2_{brand}_*.json"): - vehicles.extend(json.loads(load_fixture(vehicle_file, integration=BMW_DOMAIN))) + for vehicle_file in FIXTURE_FILES["vehicles"]: + if vehicle_file.name.startswith(brand): + vehicles.extend( + load_json_array_fixture(vehicle_file, integration=BMW_DOMAIN) + ) return httpx.Response(200, json=vehicles) def vehicle_state_sideeffect(request: httpx.Request) -> httpx.Response: """Return /vehicles/state response.""" - state_file = next(FIXTURE_PATH.rglob(f"state_{request.headers['bmw-vin']}_*.json")) try: + state_file = FIXTURE_FILES["states"][request.headers["bmw-vin"]] return httpx.Response( - 200, json=json.loads(load_fixture(state_file, integration=BMW_DOMAIN)) + 200, json=load_json_object_fixture(state_file, integration=BMW_DOMAIN) + ) + except KeyError: + return httpx.Response(404) + + +def vehicle_charging_sideeffect(request: httpx.Request) -> httpx.Response: + """Return /vehicles/state response.""" + try: + charging_file = FIXTURE_FILES["charging"][request.headers["bmw-vin"]] + return httpx.Response( + 200, json=load_json_object_fixture(charging_file, integration=BMW_DOMAIN) ) except KeyError: return httpx.Response(404) @@ -75,6 +108,10 @@ def mock_vehicles() -> respx.Router: # Get vehicle state router.get(VEHICLE_STATE_URL).mock(side_effect=vehicle_state_sideeffect) + # Get vehicle charging details + router.get(VEHICLE_CHARGING_DETAILS_URL).mock( + side_effect=vehicle_charging_sideeffect + ) return router diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json index 9c56e0595b..12e85bb852 100644 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json +++ b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json @@ -45,6 +45,64 @@ "mappingStatus": "CONFIRMED" }, "vin": "**REDACTED**", + "charging_settings": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + }, "is_metric": true, "fetched_at": "2022-07-10T11:00:00+00:00", "capabilities": { @@ -230,6 +288,7 @@ "charging_start_time_no_tz": "2022-07-10T18:01:00", "charging_end_time": null, "is_charger_connected": true, + "charging_target": 100, "account_timezone": { "_std_offset": "0:00:00", "_dst_offset": "0:00:00", @@ -376,7 +435,10 @@ "start_time": "18:01:00" }, "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING" + "charging_mode": "DELAYED_CHARGING", + "ac_current_limit": null, + "ac_available_limits": null, + "charging_preferences_service_pack": "TCB1" }, "available_attributes": [ "gps_position", @@ -384,6 +446,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -391,6 +454,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent", @@ -407,6 +475,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -414,6 +483,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent" @@ -422,6 +496,17 @@ "has_electric_drivetrain": true, "is_charging_plan_supported": true, "is_lsc_enabled": true, + "is_remote_charge_start_enabled": false, + "is_remote_charge_stop_enabled": false, + "is_remote_climate_start_enabled": true, + "is_remote_climate_stop_enabled": false, + "is_remote_horn_enabled": true, + "is_remote_lights_enabled": true, + "is_remote_lock_enabled": true, + "is_remote_sendpoi_enabled": true, + "is_remote_set_ac_limit_enabled": false, + "is_remote_set_target_soc_enabled": false, + "is_remote_unlock_enabled": true, "is_vehicle_active": false, "is_vehicle_tracking_enabled": false, "lsc_type": "ACTIVATED", @@ -433,7 +518,7 @@ ], "fingerprint": [ { - "filename": "bmw-vehicles.json", + "filename": "bmw-eadrax-vcs_v4_vehicles.json", "content": [ { "appVehicleType": "CONNECTED", @@ -476,9 +561,9 @@ } ] }, - { "filename": "mini-vehicles.json", "content": [] }, + { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, { - "filename": "bmw-vehicles_state_WBY0FINGERPRINT01.json", + "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", "content": { "capabilities": { "climateFunction": "AIR_CONDITIONING", @@ -652,6 +737,67 @@ } } } + }, + { + "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", + "content": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + } } ] } diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json index d76f2c8071..8e1fe5019c 100644 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json +++ b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json @@ -44,6 +44,64 @@ "mappingStatus": "CONFIRMED" }, "vin": "**REDACTED**", + "charging_settings": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + }, "is_metric": true, "fetched_at": "2022-07-10T11:00:00+00:00", "capabilities": { @@ -229,6 +287,7 @@ "charging_start_time_no_tz": "2022-07-10T18:01:00", "charging_end_time": null, "is_charger_connected": true, + "charging_target": 100, "account_timezone": { "_std_offset": "0:00:00", "_dst_offset": "0:00:00", @@ -375,7 +434,10 @@ "start_time": "18:01:00" }, "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING" + "charging_mode": "DELAYED_CHARGING", + "ac_current_limit": null, + "ac_available_limits": null, + "charging_preferences_service_pack": "TCB1" }, "available_attributes": [ "gps_position", @@ -383,6 +445,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -390,6 +453,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent", @@ -406,6 +474,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -413,6 +482,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent" @@ -421,6 +495,17 @@ "has_electric_drivetrain": true, "is_charging_plan_supported": true, "is_lsc_enabled": true, + "is_remote_charge_start_enabled": false, + "is_remote_charge_stop_enabled": false, + "is_remote_climate_start_enabled": true, + "is_remote_climate_stop_enabled": false, + "is_remote_horn_enabled": true, + "is_remote_lights_enabled": true, + "is_remote_lock_enabled": true, + "is_remote_sendpoi_enabled": true, + "is_remote_set_ac_limit_enabled": false, + "is_remote_set_target_soc_enabled": false, + "is_remote_unlock_enabled": true, "is_vehicle_active": false, "is_vehicle_tracking_enabled": false, "lsc_type": "ACTIVATED", @@ -431,7 +516,7 @@ }, "fingerprint": [ { - "filename": "bmw-vehicles.json", + "filename": "bmw-eadrax-vcs_v4_vehicles.json", "content": [ { "appVehicleType": "CONNECTED", @@ -474,9 +559,9 @@ } ] }, - { "filename": "mini-vehicles.json", "content": [] }, + { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, { - "filename": "bmw-vehicles_state_WBY0FINGERPRINT01.json", + "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", "content": { "capabilities": { "climateFunction": "AIR_CONDITIONING", @@ -650,6 +735,67 @@ } } } + }, + { + "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", + "content": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + } } ] } diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json new file mode 100644 index 0000000000..03bfc1cae0 --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json @@ -0,0 +1,60 @@ +{ + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { + "showDepartureTimers": false + } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" +} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles.json similarity index 100% rename from tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json rename to tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles.json diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json similarity index 100% rename from tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json rename to tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json From 10cf92246fb3144cd20d627b50c6c8a3abe8f42e Mon Sep 17 00:00:00 2001 From: On Freund Date: Thu, 23 Mar 2023 11:05:08 +0200 Subject: [PATCH 0071/1165] Bump pyrympro to 0.0.7 (#90118) --- homeassistant/components/rympro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json index 613a1c3361..e14ac9af71 100644 --- a/homeassistant/components/rympro/manifest.json +++ b/homeassistant/components/rympro/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rympro", "iot_class": "cloud_polling", - "requirements": ["pyrympro==0.0.4"] + "requirements": ["pyrympro==0.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index a3ddeac5db..0b472aafbd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1923,7 +1923,7 @@ pyroute2==0.7.5 pyruckus==0.16 # homeassistant.components.rympro -pyrympro==0.0.4 +pyrympro==0.0.7 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5237078f5e..1dcc06fa41 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1397,7 +1397,7 @@ pyroute2==0.7.5 pyruckus==0.16 # homeassistant.components.rympro -pyrympro==0.0.4 +pyrympro==0.0.7 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 From 2cb4ec82df5eec8ab1d9b7f8b87ac5cbd33a43a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 23:08:06 -1000 Subject: [PATCH 0072/1165] Bump yalexs-ble to 2.1.2 (#90156) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 213f0237e1..7bbc6f042e 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.1"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.2"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 6bb58752a0..bb95a70386 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.1"] + "requirements": ["yalexs-ble==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b472aafbd..34e4707607 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2671,7 +2671,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.1 +yalexs-ble==2.1.2 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1dcc06fa41..fbc282040f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.1 +yalexs-ble==2.1.2 # homeassistant.components.august yalexs==1.2.7 From 4c26741e40316a966caf4bdff783cea5f3277ef8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:09:03 +0100 Subject: [PATCH 0073/1165] Bump actions/stale from 7.0.0 to 8.0.0 (#90155) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d8aaa998ac..5fb977f74d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: # - No PRs marked as no-stale # - No issues (-1) - name: 90 days stale PRs policy - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 @@ -53,7 +53,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -83,7 +83,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" From 50ea0c5cf2beba8fa535ae4091ad53985a1036fa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Mar 2023 10:12:42 +0100 Subject: [PATCH 0074/1165] Tweak multiprotocol tests (#90163) --- .../test_silabs_multiprotocol_addon.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 57e4a23ab5..424e4126e0 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -25,7 +25,7 @@ from tests.common import ( TEST_DOMAIN = "test" -class TestConfigFlow(ConfigFlow): +class FakeConfigFlow(ConfigFlow): """Handle a config flow for the silabs multiprotocol add-on.""" VERSION = 1 @@ -34,9 +34,9 @@ class TestConfigFlow(ConfigFlow): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> TestOptionsFlow: + ) -> FakeOptionsFlow: """Return the options flow.""" - return TestOptionsFlow(config_entry) + return FakeOptionsFlow(config_entry) async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle the initial step.""" @@ -46,7 +46,7 @@ class TestConfigFlow(ConfigFlow): return self.async_create_entry(title="Test HW", data={}) -class TestOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): +class FakeOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): """Handle an option flow for the silabs multiprotocol add-on.""" async def _async_serial_port_settings( @@ -89,10 +89,10 @@ class TestOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): @pytest.fixture(autouse=True) def config_flow_handler( hass: HomeAssistant, current_request_with_host: Any -) -> Generator[TestConfigFlow, None, None]: +) -> Generator[FakeConfigFlow, None, None]: """Fixture for a test config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with mock_config_flow(TEST_DOMAIN, TestConfigFlow): + with mock_config_flow(TEST_DOMAIN, FakeConfigFlow): yield From 60ae1f99e023fd4d2280e4a98aa42c46f29a8b8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 23:13:18 -1000 Subject: [PATCH 0075/1165] Update powerwall strings for newer models (#90151) --- homeassistant/components/powerwall/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index 213b7cc03d..db8b212cc5 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -3,15 +3,15 @@ "flow_title": "{name} ({ip_address})", "step": { "user": { - "title": "Connect to the powerwall", - "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Tesla app or the last 5 characters of the password found inside the door for Backup Gateway 2.", + "title": "Connect to the Powerwall", + "description": "The default password is printed inside the Backup Gateway for newer models. For older models, the default password is the last five characters of the serial number for Backup Gateway and can be found in the Tesla app.", "data": { "ip_address": "[%key:common::config_flow::data::ip%]", "password": "[%key:common::config_flow::data::password%]" } }, "reauth_confim": { - "title": "Reauthenticate the powerwall", + "title": "Reauthenticate the Powerwall", "description": "[%key:component::powerwall::config::step::user::description%]", "data": { "password": "[%key:common::config_flow::data::password%]" @@ -24,7 +24,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "wrong_version": "Your powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved.", + "wrong_version": "Your Powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved.", "unknown": "[%key:common::config_flow::error::unknown%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, From 92bcb04e4fea58c74baa83708aeb93fe9a3db40f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:13:53 +0100 Subject: [PATCH 0076/1165] Adjust scaffold docstring (#90157) Co-authored-by: Martin Hjelmare --- script/scaffold/templates/config_flow/tests/conftest.py | 2 +- script/scaffold/templates/config_flow_helper/tests/conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/script/scaffold/templates/config_flow/tests/conftest.py b/script/scaffold/templates/config_flow/tests/conftest.py index dab3d971a3..05993acc32 100644 --- a/script/scaffold/templates/config_flow/tests/conftest.py +++ b/script/scaffold/templates/config_flow/tests/conftest.py @@ -1,4 +1,4 @@ -"""Test the NEW_NAME config flow.""" +"""Common fixtures for the NEW_NAME tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, patch diff --git a/script/scaffold/templates/config_flow_helper/tests/conftest.py b/script/scaffold/templates/config_flow_helper/tests/conftest.py index dab3d971a3..05993acc32 100644 --- a/script/scaffold/templates/config_flow_helper/tests/conftest.py +++ b/script/scaffold/templates/config_flow_helper/tests/conftest.py @@ -1,4 +1,4 @@ -"""Test the NEW_NAME config flow.""" +"""Common fixtures for the NEW_NAME tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, patch From 568a731e2d28ca062af78c9038bbdd2e2891f8bf Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:30:31 +0100 Subject: [PATCH 0077/1165] Use snapshot test for Landis+Gyr (#90126) Initial commit for snapshot test --- .../snapshots/test_sensor.ambr | 303 ++++++++++++++++++ .../landisgyr_heat_meter/test_sensor.py | 54 +--- 2 files changed, 310 insertions(+), 47 deletions(-) create mode 100644 tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr diff --git a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr new file mode 100644 index 0000000000..e149073d9c --- /dev/null +++ b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_create_sensors + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage', + 'last_changed': , + 'last_updated': , + 'state': '456.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Meter Heat usage GJ', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_usage_gj', + 'last_changed': , + 'last_updated': , + 'state': '123.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Heat previous year GJ', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_previous_year_gj', + 'last_changed': , + 'last_updated': , + 'state': '111.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage previous year', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Ownership number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_ownership_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Error number', + 'icon': 'mdi:home-alert', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_error_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Device number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_device_number', + 'last_changed': , + 'last_updated': , + 'state': 'devicenr_789', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Measurement period minutes', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measurement_period_minutes', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max previous year', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Operating hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_operating_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Flow hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Yearly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_yearly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Monthly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_monthly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Heat Meter Meter date time', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_meter_date_time', + 'last_changed': , + 'last_updated': , + 'state': '2022-05-20T02:41:17+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Measuring range', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measuring_range', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Settings and firmware', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_settings_and_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index a37fab65a1..4de58a206e 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -4,23 +4,11 @@ import datetime from unittest.mock import patch import serial +from syrupy import SnapshotAssertion from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.components.landisgyr_heat_meter.const import DOMAIN, POLLING_INTERVAL -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, - EntityCategory, - UnitOfEnergy, - UnitOfVolume, -) +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -46,7 +34,10 @@ class MockHeatMeterResponse: @patch(API_HEAT_METER_SERVICE) async def test_create_sensors( - mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry + mock_heat_meter, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test sensor.""" entry_data = { @@ -55,7 +46,6 @@ async def test_create_sensors( "device_number": "123456789", } mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) - mock_entry.add_to_hass(hass) mock_heat_meter_response = MockHeatMeterResponse( @@ -72,37 +62,7 @@ async def test_create_sensors( await async_setup_component(hass, HA_DOMAIN, {}) await hass.async_block_till_done() - # check if 26 attributes have been created - assert len(hass.states.async_all()) == 25 - - state = hass.states.get("sensor.heat_meter_heat_usage_gj") - assert state - assert state.state == "123.0" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.GIGA_JOULE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - - state = hass.states.get("sensor.heat_meter_volume_usage") - assert state - assert state.state == "456.0" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - - state = hass.states.get("sensor.heat_meter_device_number") - assert state - assert state.state == "devicenr_789" - assert state.attributes.get(ATTR_STATE_CLASS) is None - entity_registry_entry = entity_registry.async_get("sensor.heat_meter_device_number") - assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC - - state = hass.states.get("sensor.heat_meter_meter_date_time") - assert state - assert state.attributes.get(ATTR_ICON) == "mdi:clock-outline" - assert state.attributes.get(ATTR_STATE_CLASS) is None - entity_registry_entry = entity_registry.async_get( - "sensor.heat_meter_meter_date_time" - ) - assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC + assert hass.states.async_all() == snapshot @patch(API_HEAT_METER_SERVICE) From 2b4514ae25b795926a5d07b2dc2472a3d8415ef5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 23 Mar 2023 11:53:22 +0100 Subject: [PATCH 0078/1165] Add codeowner Workday (#90167) codeowner --- CODEOWNERS | 4 ++-- homeassistant/components/workday/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index afab5f8885..617fc46c27 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1356,8 +1356,8 @@ build.json @home-assistant/supervisor /tests/components/wled/ @frenck /homeassistant/components/wolflink/ @adamkrol93 /tests/components/wolflink/ @adamkrol93 -/homeassistant/components/workday/ @fabaff -/tests/components/workday/ @fabaff +/homeassistant/components/workday/ @fabaff @gjohansson-ST +/tests/components/workday/ @fabaff @gjohansson-ST /homeassistant/components/worldclock/ @fabaff /tests/components/worldclock/ @fabaff /homeassistant/components/ws66i/ @ssaenger diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 442456066c..4c10141406 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -1,7 +1,7 @@ { "domain": "workday", "name": "Workday", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@gjohansson-ST"], "documentation": "https://www.home-assistant.io/integrations/workday", "iot_class": "local_polling", "loggers": [ From dd4a3089ec52f97d9dd0b8a4828362395d078124 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 23 Mar 2023 11:54:15 +0100 Subject: [PATCH 0079/1165] Add constants file for Brottsplatskartan (#90165) * bpk constants * not used --- .../components/brottsplatskartan/const.py | 33 +++++++++++++++++++ .../components/brottsplatskartan/sensor.py | 33 ++----------------- 2 files changed, 35 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/brottsplatskartan/const.py diff --git a/homeassistant/components/brottsplatskartan/const.py b/homeassistant/components/brottsplatskartan/const.py new file mode 100644 index 0000000000..87c42b01f4 --- /dev/null +++ b/homeassistant/components/brottsplatskartan/const.py @@ -0,0 +1,33 @@ +"""Adds constants for brottsplatskartan integration.""" + +import logging + +LOGGER = logging.getLogger(__package__) + +CONF_AREA = "area" +DEFAULT_NAME = "Brottsplatskartan" + +AREAS = [ + "N/A", + "Blekinge län", + "Dalarnas län", + "Gotlands län", + "Gävleborgs län", + "Hallands län", + "Jämtlands län", + "Jönköpings län", + "Kalmar län", + "Kronobergs län", + "Norrbottens län", + "Skåne län", + "Stockholms län", + "Södermanlands län", + "Uppsala län", + "Värmlands län", + "Västerbottens län", + "Västernorrlands län", + "Västmanlands län", + "Västra Götalands län", + "Örebro län", + "Östergötlands län", +] diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index d76cb7c8a5..da53a9fc0e 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta -import logging import uuid import brottsplatskartan @@ -16,38 +15,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) - -CONF_AREA = "area" - -DEFAULT_NAME = "Brottsplatskartan" +from .const import AREAS, CONF_AREA, DEFAULT_NAME, LOGGER SCAN_INTERVAL = timedelta(minutes=30) -AREAS = [ - "Blekinge län", - "Dalarnas län", - "Gotlands län", - "Gävleborgs län", - "Hallands län", - "Jämtlands län", - "Jönköpings län", - "Kalmar län", - "Kronobergs län", - "Norrbottens län", - "Skåne län", - "Stockholms län", - "Södermanlands län", - "Uppsala län", - "Värmlands län", - "Västerbottens län", - "Västernorrlands län", - "Västmanlands län", - "Västra Götalands län", - "Örebro län", - "Östergötlands län", -] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, @@ -99,7 +70,7 @@ class BrottsplatskartanSensor(SensorEntity): incidents = self._brottsplatskartan.get_incidents() if incidents is False: - _LOGGER.debug("Problems fetching incidents") + LOGGER.debug("Problems fetching incidents") return for incident in incidents: From b1370cbd428ce39a97cb4f66f69030c53be08b25 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 23 Mar 2023 12:01:03 +0100 Subject: [PATCH 0080/1165] Add constants file to workday (#90168) Constants workday --- .../components/workday/binary_sensor.py | 56 +++++++++---------- homeassistant/components/workday/const.py | 25 +++++++++ 2 files changed, 50 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/workday/const.py diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index e66efa039a..cfd04dd30d 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import date, timedelta -import logging from typing import Any import holidays @@ -13,31 +12,28 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, BinarySensorEntity, ) -from homeassistant.const import CONF_NAME, WEEKDAYS +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt -_LOGGER = logging.getLogger(__name__) - -ALLOWED_DAYS = WEEKDAYS + ["holiday"] - -CONF_COUNTRY = "country" -CONF_PROVINCE = "province" -CONF_WORKDAYS = "workdays" -CONF_EXCLUDES = "excludes" -CONF_OFFSET = "days_offset" -CONF_ADD_HOLIDAYS = "add_holidays" -CONF_REMOVE_HOLIDAYS = "remove_holidays" - -# By default, Monday - Friday are workdays -DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"] -# By default, public holidays, Saturdays and Sundays are excluded from workdays -DEFAULT_EXCLUDES = ["sat", "sun", "holiday"] -DEFAULT_NAME = "Workday Sensor" -DEFAULT_OFFSET = 0 +from .const import ( + ALLOWED_DAYS, + CONF_ADD_HOLIDAYS, + CONF_COUNTRY, + CONF_EXCLUDES, + CONF_OFFSET, + CONF_PROVINCE, + CONF_REMOVE_HOLIDAYS, + CONF_WORKDAYS, + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, + LOGGER, +) def valid_country(value: Any) -> str: @@ -106,14 +102,14 @@ def setup_platform( ): obj_holidays = getattr(holidays, country)(subdiv=province, years=year) else: - _LOGGER.error("There is no subdivision %s in country %s", province, country) + LOGGER.error("There is no subdivision %s in country %s", province, country) return # Add custom holidays try: obj_holidays.append(add_holidays) except TypeError: - _LOGGER.debug("No custom holidays or invalid holidays") + LOGGER.debug("No custom holidays or invalid holidays") # Remove holidays try: @@ -123,25 +119,23 @@ def setup_platform( if dt.parse_date(remove_holiday): # remove holiday by date removed = obj_holidays.pop(remove_holiday) - _LOGGER.debug("Removed %s", remove_holiday) + LOGGER.debug("Removed %s", remove_holiday) else: # remove holiday by name - _LOGGER.debug("Treating '%s' as named holiday", remove_holiday) + LOGGER.debug("Treating '%s' as named holiday", remove_holiday) removed = obj_holidays.pop_named(remove_holiday) for holiday in removed: - _LOGGER.debug( - "Removed %s by name '%s'", holiday, remove_holiday - ) + LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) except KeyError as unmatched: - _LOGGER.warning("No holiday found matching %s", unmatched) + LOGGER.warning("No holiday found matching %s", unmatched) except TypeError: - _LOGGER.debug("No holidays to remove or invalid holidays") + LOGGER.debug("No holidays to remove or invalid holidays") - _LOGGER.debug("Found the following holidays for your configuration:") + LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): # Make explicit str variable to avoid "Incompatible types in assignment" _holiday_string = holiday_date.strftime("%Y-%m-%d") - _LOGGER.debug("%s %s", _holiday_string, name) + LOGGER.debug("%s %s", _holiday_string, name) add_entities( [IsWorkdaySensor(obj_holidays, workdays, excludes, days_offset, sensor_name)], diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py new file mode 100644 index 0000000000..9ebf85f1c2 --- /dev/null +++ b/homeassistant/components/workday/const.py @@ -0,0 +1,25 @@ +"""Add constants for Workday integration.""" +from __future__ import annotations + +import logging + +from homeassistant.const import WEEKDAYS + +LOGGER = logging.getLogger(__name__) + +ALLOWED_DAYS = WEEKDAYS + ["holiday"] + +CONF_COUNTRY = "country" +CONF_PROVINCE = "province" +CONF_WORKDAYS = "workdays" +CONF_EXCLUDES = "excludes" +CONF_OFFSET = "days_offset" +CONF_ADD_HOLIDAYS = "add_holidays" +CONF_REMOVE_HOLIDAYS = "remove_holidays" + +# By default, Monday - Friday are workdays +DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"] +# By default, public holidays, Saturdays and Sundays are excluded from workdays +DEFAULT_EXCLUDES = ["sat", "sun", "holiday"] +DEFAULT_NAME = "Workday Sensor" +DEFAULT_OFFSET = 0 From d5f949f4d8a09d9ba31c7859466535e341bf09ee Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 23 Mar 2023 13:42:39 +0100 Subject: [PATCH 0081/1165] Update pydantic to 1.10.7 (#90164) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index e2db9d1e9f..fa16881520 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ freezegun==1.2.2 mock-open==1.4.0 mypy==1.1.1 pre-commit==3.1.0 -pydantic==1.10.6 +pydantic==1.10.7 pylint==2.17.0 pylint-per-file-ignores==1.1.0 pipdeptree==2.5.0 From 504793882d50a1f8b878f0f1cc3c81fc9fe97ad7 Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Thu, 23 Mar 2023 10:09:00 -0500 Subject: [PATCH 0082/1165] Remove unecessary variable in control4 (#90176) Remove unecessary expiration value --- homeassistant/components/control4/__init__.py | 2 -- homeassistant/components/control4/const.py | 1 - homeassistant/components/control4/director_utils.py | 10 +--------- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index c99af1f89c..de4c8208ee 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -35,7 +35,6 @@ from .const import ( CONF_DIRECTOR_ALL_ITEMS, CONF_DIRECTOR_MODEL, CONF_DIRECTOR_SW_VERSION, - CONF_DIRECTOR_TOKEN_EXPIRATION, DEFAULT_SCAN_INTERVAL, DOMAIN, ) @@ -79,7 +78,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) entry_data[CONF_DIRECTOR] = director - entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_dict["token_expiration"] # Add Control4 controller to device registry controller_href = (await account.getAccountControllers())["href"] diff --git a/homeassistant/components/control4/const.py b/homeassistant/components/control4/const.py index 2759088198..677610a161 100644 --- a/homeassistant/components/control4/const.py +++ b/homeassistant/components/control4/const.py @@ -7,7 +7,6 @@ MIN_SCAN_INTERVAL = 1 CONF_ACCOUNT = "account" CONF_DIRECTOR = "director" -CONF_DIRECTOR_TOKEN_EXPIRATION = "director_token_expiry" CONF_DIRECTOR_SW_VERSION = "director_sw_version" CONF_DIRECTOR_MODEL = "director_model" CONF_DIRECTOR_ALL_ITEMS = "director_all_items" diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index fc4ca9e358..bab8c8634c 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -10,13 +10,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERN from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import ( - CONF_ACCOUNT, - CONF_CONTROLLER_UNIQUE_ID, - CONF_DIRECTOR, - CONF_DIRECTOR_TOKEN_EXPIRATION, - DOMAIN, -) +from .const import CONF_ACCOUNT, CONF_CONTROLLER_UNIQUE_ID, CONF_DIRECTOR, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -53,10 +47,8 @@ async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): director = C4Director( config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) - director_token_expiry = director_token_dict["token_expiration"] _LOGGER.debug("Saving new tokens in hass data") entry_data = hass.data[DOMAIN][entry.entry_id] entry_data[CONF_ACCOUNT] = account entry_data[CONF_DIRECTOR] = director - entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_expiry From 73ed6e039ae77c6d7e0b19d1750fae2efedc8314 Mon Sep 17 00:00:00 2001 From: PeteRager <76050312+PeteRager@users.noreply.github.com> Date: Thu, 23 Mar 2023 11:15:55 -0400 Subject: [PATCH 0083/1165] Improve logging for unavailable sonos hosts (#90172) * Repeated warning messages on unavailable manually specified hosts Sonos logs warning messages every 1 minute 12 seconds for hosts that are not on-line. This fixes the issue and the warning will be logged the first time, and subsequent logs messages will be at DEBUG level * Update homeassistant/components/sonos/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Log info message when reconnect succeeds * Use pop to simplify code * Add additional test, fix key error with pop * Use pop with default return value * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update comment, remove unneeded line of code --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/sonos/__init__.py | 16 ++++- tests/components/sonos/test_init.py | 71 +++++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index e181e995c7..4b68030f84 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -177,6 +177,7 @@ class SonosDiscoveryManager: self.entry = entry self.data = data self.hosts = set(hosts) + self.hosts_in_error: dict[str, bool] = {} self.discovery_lock = asyncio.Lock() self.creation_lock = asyncio.Lock() self._known_invisible: set[SoCo] = set() @@ -353,10 +354,19 @@ class SonosDiscoveryManager: soco, ) except (OSError, SoCoException, Timeout) as ex: - _LOGGER.warning( - "Could not get visible Sonos devices from %s: %s", ip_addr, ex - ) + if not self.hosts_in_error.get(ip_addr): + _LOGGER.warning( + "Could not get visible Sonos devices from %s: %s", ip_addr, ex + ) + self.hosts_in_error[ip_addr] = True + else: + _LOGGER.debug( + "Could not get visible Sonos devices from %s: %s", ip_addr, ex + ) + else: + if self.hosts_in_error.pop(ip_addr, None): + _LOGGER.info("Connection restablished to Sonos device %s", ip_addr) if new_hosts := { x.ip_address for x in visible_zones diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index fc063991e6..6cc79e1b2f 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,8 +1,13 @@ """Tests for the Sonos config flow.""" -from unittest.mock import patch +import logging +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import sonos, zeroconf +from homeassistant.components.sonos import SonosDiscoveryManager +from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -63,3 +68,67 @@ async def test_not_configuring_sonos_not_creates_entry(hass: HomeAssistant) -> N await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 0 + + +async def test_async_poll_manual_hosts_warnings( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that host warnings are not logged repeatedly.""" + await async_setup_component( + hass, + sonos.DOMAIN, + {"sonos": {"media_player": {"interface_addr": "127.0.0.1"}}}, + ) + await hass.async_block_till_done() + manager: SonosDiscoveryManager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] + manager.hosts.add("10.10.10.10") + with caplog.at_level(logging.DEBUG), patch.object( + manager, "_async_handle_discovery_message" + ), patch("homeassistant.components.sonos.async_call_later"), patch( + "homeassistant.components.sonos.async_dispatcher_send" + ), patch.object( + hass, "async_add_executor_job", new=AsyncMock() + ) as mock_async_add_executor_job: + mock_async_add_executor_job.side_effect = [ + OSError(), + OSError(), + [], + [], + OSError(), + ] + # First call fails, it should be logged as a WARNING message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "WARNING" + assert "Could not get visible Sonos devices from" in record.message + + # Second call fails again, it should be logged as a DEBUG message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "DEBUG" + assert "Could not get visible Sonos devices from" in record.message + + # Third call succeeds, it should log an info message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "INFO" + assert "Connection restablished to Sonos device" in record.message + + # Fourth call succeeds again, no need to log + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 0 + + # Fifth call fail again again, should be logged as a WARNING message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "WARNING" + assert "Could not get visible Sonos devices from" in record.message From cb578c71e03e0187b5fc6a516e5d9ec11421a8e1 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 23 Mar 2023 17:21:21 +0100 Subject: [PATCH 0084/1165] Add Landis+Gyr missing device class (#90182) Add missing device class for heat_previous_year_gj --- homeassistant/components/landisgyr_heat_meter/sensor.py | 1 + tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index af96629742..244515a07d 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -77,6 +77,7 @@ HEAT_METER_SENSOR_TYPES = ( icon="mdi:fire", name="Heat previous year GJ", native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, + device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda res: getattr(res, "heat_previous_year_gj", None), ), diff --git a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr index e149073d9c..9c62ca3f94 100644 --- a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr +++ b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr @@ -31,6 +31,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Heat Meter Heat previous year GJ', 'icon': 'mdi:fire', 'unit_of_measurement': , From e290febb384d7757f96febdf285bbdc817e255ab Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Thu, 23 Mar 2023 11:34:38 -0500 Subject: [PATCH 0085/1165] Bump pyControl4 to 1.1.0 (#90115) * Bump pyControl4 to 1.1.0 * Remove mock token_expiration from control4 --- homeassistant/components/control4/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/control4/test_config_flow.py | 6 +----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/control4/manifest.json b/homeassistant/components/control4/manifest.json index 125e3c2e38..765f0dce78 100644 --- a/homeassistant/components/control4/manifest.json +++ b/homeassistant/components/control4/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/control4", "iot_class": "local_polling", "loggers": ["pyControl4"], - "requirements": ["pyControl4==0.0.6"], + "requirements": ["pyControl4==1.1.0"], "ssdp": [ { "st": "c4:director" diff --git a/requirements_all.txt b/requirements_all.txt index 34e4707607..7d1670833f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1457,7 +1457,7 @@ py17track==2021.12.2 pyCEC==0.5.2 # homeassistant.components.control4 -pyControl4==0.0.6 +pyControl4==1.1.0 # homeassistant.components.met_eireann pyMetEireann==2021.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbc282040f..3bb56a1be4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1066,7 +1066,7 @@ py17track==2021.12.2 pyCEC==0.5.2 # homeassistant.components.control4 -pyControl4==0.0.6 +pyControl4==1.1.0 # homeassistant.components.met_eireann pyMetEireann==2021.8.0 diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py index a40047eccd..4909ead6c4 100644 --- a/tests/components/control4/test_config_flow.py +++ b/tests/components/control4/test_config_flow.py @@ -1,5 +1,4 @@ """Test the Control4 config flow.""" -import datetime from unittest.mock import AsyncMock, patch from pyControl4.account import C4Account @@ -25,10 +24,7 @@ def _get_mock_c4_account( "href": "https://apis.control4.com/account/v3/rest/accounts/000000", "name": "Name", }, - getDirectorBearerToken={ - "token": "token", - "token_expiration": datetime.datetime(2020, 7, 15, 13, 50, 15, 26940), - }, + getDirectorBearerToken={"token": "token"}, ): c4_account_mock = AsyncMock(C4Account) From 87475e8ff6e4b5cb9ad01bf5bdc59115ad0772b3 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 23 Mar 2023 17:34:57 +0100 Subject: [PATCH 0086/1165] Cleanup some leftovers for Landis+Gyr (#90183) Cleanup some leftovers --- homeassistant/components/landisgyr_heat_meter/const.py | 1 - homeassistant/components/landisgyr_heat_meter/manifest.json | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/const.py b/homeassistant/components/landisgyr_heat_meter/const.py index 56f5980a83..079bcad253 100644 --- a/homeassistant/components/landisgyr_heat_meter/const.py +++ b/homeassistant/components/landisgyr_heat_meter/const.py @@ -4,6 +4,5 @@ from datetime import timedelta DOMAIN = "landisgyr_heat_meter" -GJ_TO_MWH = 0.277778 # conversion factor ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time POLLING_INTERVAL = timedelta(days=1) # Polling is only daily to prevent battery drain. diff --git a/homeassistant/components/landisgyr_heat_meter/manifest.json b/homeassistant/components/landisgyr_heat_meter/manifest.json index 5e10f39418..a056f1f656 100644 --- a/homeassistant/components/landisgyr_heat_meter/manifest.json +++ b/homeassistant/components/landisgyr_heat_meter/manifest.json @@ -5,9 +5,6 @@ "config_flow": true, "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", - "homekit": {}, "iot_class": "local_polling", - "requirements": ["ultraheat-api==0.5.1"], - "ssdp": [], - "zeroconf": [] + "requirements": ["ultraheat-api==0.5.1"] } From 8fd88d6703ce2d6f9b4235e2fd9015774abc2ca4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:13:36 +0100 Subject: [PATCH 0087/1165] Prepare MQTT platform tests part2 (#90105) * Tests button * Tests camera * Tests climate --- tests/components/mqtt/test_button.py | 88 +-- tests/components/mqtt/test_camera.py | 81 ++- tests/components/mqtt/test_climate.py | 902 ++++++++++++++++---------- 3 files changed, 650 insertions(+), 421 deletions(-) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index cdb3d0fbf3..37636ff4bf 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -13,7 +13,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -57,13 +56,9 @@ def button_platform_only(): @pytest.mark.freeze_time("2021-11-08 13:31:44+00:00") -async def test_sending_mqtt_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: { @@ -74,10 +69,14 @@ async def test_sending_mqtt_commands( "qos": "2", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("button.test_button") assert state.state == STATE_UNKNOWN @@ -98,13 +97,9 @@ async def test_sending_mqtt_commands( assert state.state == "2021-11-08T13:31:44+00:00" -async def test_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of MQTT commands through a command template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: { @@ -114,10 +109,14 @@ async def test_command_template( "payload_press": "milky_way_press", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_command_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of MQTT commands through a command template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("button.test") assert state.state == STATE_UNKNOWN @@ -436,11 +435,9 @@ async def test_entity_debug_info_message( ) -async def test_invalid_device_class(hass: HomeAssistant) -> None: - """Test device_class option with invalid value.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: { @@ -449,17 +446,20 @@ async def test_invalid_device_class(hass: HomeAssistant) -> None: "device_class": "foobarnotreal", } } - }, - ) - - -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: - """Test device_class option with valid values.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test device_class option with invalid value.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: [ @@ -479,10 +479,14 @@ async def test_valid_device_class( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test device_class option with valid values.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("button.test_1") assert state.attributes["device_class"] == button.ButtonDeviceClass.UPDATE diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 90020bce48..27d575ce4e 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -10,7 +10,6 @@ from homeassistant.components import camera, mqtt from homeassistant.components.mqtt.camera import MQTT_CAMERA_ATTRIBUTES_BLOCKED from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -56,20 +55,18 @@ def camera_platform_only(): yield +@pytest.mark.parametrize( + "hass_config", + [{mqtt.DOMAIN: {camera.DOMAIN: {"topic": "test/camera", "name": "Test Camera"}}}], +) async def test_run_camera_setup( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test that it fetches the given payload.""" topic = "test/camera" - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {camera.DOMAIN: {"topic": topic, "name": "Test Camera"}}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() url = hass.states.get("camera.test_camera").attributes["entity_picture"] @@ -82,28 +79,28 @@ async def test_run_camera_setup( assert body == "beer" -async def test_run_camera_b64_encoded( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test that it fetches the given encoded payload.""" - topic = "test/camera" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { camera.DOMAIN: { - "topic": topic, + "topic": "test/camera", "name": "Test Camera", "image_encoding": "b64", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_camera_b64_encoded( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test that it fetches the given encoded payload.""" + topic = "test/camera" + await mqtt_mock_entry_no_yaml_config() url = hass.states.get("camera.test_camera").attributes["entity_picture"] @@ -116,31 +113,31 @@ async def test_run_camera_b64_encoded( assert body == "grass" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "camera": { + "topic": "test/camera", + "name": "Test Camera", + "encoding": "utf-8", + "image_encoding": "b64", + "availability": {"topic": "test/camera_availability"}, + } + } + } + ], +) async def test_camera_b64_encoded_with_availability( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test availability works if b64 encoding is turned on.""" topic = "test/camera" topic_availability = "test/camera_availability" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - "camera": { - "topic": topic, - "name": "Test Camera", - "encoding": "utf-8", - "image_encoding": "b64", - "availability": {"topic": topic_availability}, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # Make sure we are available async_fire_mqtt_message(hass, topic_availability, "online") diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 9a9a9d81e8..36894ba2cd 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -30,9 +30,9 @@ from homeassistant.components.climate import ( from homeassistant.components.mqtt.climate import MQTT_CLIMATE_ATTRIBUTES_BLOCKED from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -102,13 +102,12 @@ def climate_platform_only(): yield +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_params( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the initial parameters.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -121,49 +120,101 @@ async def test_setup_params( assert state.attributes.get("max_humidity") == DEFAULT_MAX_HUMIDITY +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"preset_modes": ["auto", "home", "none"]},), + ) + ], +) async def test_preset_none_in_preset_modes( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test the preset mode payload reset configuration.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][climate.DOMAIN]) - config["preset_modes"].append("none") - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {climate.DOMAIN: config}} - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert "Invalid config for [mqtt]: not a valid value" in caplog.text @pytest.mark.parametrize( - ("parameter", "config_value"), + ("hass_config", "parameter"), [ - ("away_mode_command_topic", "away-mode-command-topic"), - ("away_mode_state_topic", "away-mode-state-topic"), - ("away_mode_state_template", "{{ value_json }}"), - ("hold_mode_command_topic", "hold-mode-command-topic"), - ("hold_mode_command_template", "hold-mode-command-template"), - ("hold_mode_state_topic", "hold-mode-state-topic"), - ("hold_mode_state_template", "{{ value_json }}"), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"away_mode_command_topic": "away-mode-command-topic"},), + ), + "away_mode_command_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"away_mode_state_topic": "away-mode-state-topic"},), + ), + "away_mode_state_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"away_mode_state_template": "{{ value_json }}"},), + ), + "away_mode_state_template", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_command_topic": "hold-mode-command-topic"},), + ), + "hold_mode_command_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_command_template": "hold-mode-command-template"},), + ), + "hold_mode_command_template", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_state_topic": "hold-mode-state-topic"},), + ), + "hold_mode_state_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_state_template": "{{ value_json }}"},), + ), + "hold_mode_state_template", + ), ], ) async def test_preset_modes_deprecation_guard( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, parameter, config_value + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, parameter: str ) -> None: """Test the configuration for invalid legacy parameters.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][climate.DOMAIN]) - config[parameter] = config_value - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {climate.DOMAIN: config}} - ) assert f"[{parameter}] is an invalid option for [mqtt]. Check: mqtt->mqtt->climate->0->{parameter}" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the supported_features.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) support = ( @@ -179,13 +230,12 @@ async def test_supported_features( assert state.attributes.get("supported_features") == support +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_get_hvac_modes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the operation list returns the correct modes.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) modes = state.attributes.get("hvac_modes") @@ -199,18 +249,17 @@ async def test_get_hvac_modes( ] == modes +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_operation_bad_attr_and_state( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting operation mode without required attribute. Also check the state. """ - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -224,13 +273,12 @@ async def test_set_operation_bad_attr_and_state( assert state.state == "off" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_operation( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new operation mode.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -241,15 +289,20 @@ async def test_set_operation( mqtt_mock.async_publish.assert_called_once_with("mode-topic", "cool", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"mode_state_topic": "mode-state"},) + ) + ], +) async def test_set_operation_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting operation mode in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["mode_state_topic"] = "mode-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "unknown" @@ -267,16 +320,21 @@ async def test_set_operation_pessimistic( assert state.state == "cool" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"mode_state_topic": "mode-state", "optimistic": True},), + ) + ], +) async def test_set_operation_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting operation mode in optimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["mode_state_topic"] = "mode-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -297,15 +355,19 @@ async def test_set_operation_optimistic( # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, # support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE was already removed or never added # support was deprecated with release 2023.2 and will be removed with release 2023.8 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"power_command_topic": "power-command"},) + ) + ], +) async def test_set_operation_with_power_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new operation mode with power command enabled.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["power_command_topic"] = "power-command" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -326,15 +388,14 @@ async def test_set_operation_with_power_command( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_fan_mode_bad_attr( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting fan mode without required attribute.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -347,15 +408,19 @@ async def test_set_fan_mode_bad_attr( assert state.attributes.get("fan_mode") == "low" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"fan_mode_state_topic": "fan-state"},) + ) + ], +) async def test_set_fan_mode_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["fan_mode_state_topic"] = "fan-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") is None @@ -373,16 +438,21 @@ async def test_set_fan_mode_pessimistic( assert state.attributes.get("fan_mode") == "high" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"fan_mode_state_topic": "fan-state", "optimistic": True},), + ) + ], +) async def test_set_fan_mode_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode in optimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["fan_mode_state_topic"] = "fan-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -400,13 +470,12 @@ async def test_set_fan_mode_optimistic( assert state.attributes.get("fan_mode") == "low" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_fan_mode( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -416,15 +485,14 @@ async def test_set_fan_mode( assert state.attributes.get("fan_mode") == "high" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing_mode_bad_attr( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting swing mode without required attribute.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -437,15 +505,19 @@ async def test_set_swing_mode_bad_attr( assert state.attributes.get("swing_mode") == "off" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"swing_mode_state_topic": "swing-state"},) + ) + ], +) async def test_set_swing_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting swing mode in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["swing_mode_state_topic"] = "swing-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None @@ -463,16 +535,21 @@ async def test_set_swing_pessimistic( assert state.attributes.get("swing_mode") == "on" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"swing_mode_state_topic": "swing-state", "optimistic": True},), + ) + ], +) async def test_set_swing_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting swing mode in optimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["swing_mode_state_topic"] = "swing-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -490,13 +567,12 @@ async def test_set_swing_optimistic( assert state.attributes.get("swing_mode") == "off" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new swing mode.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -506,13 +582,12 @@ async def test_set_swing( assert state.attributes.get("swing_mode") == "on" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_temperature( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -545,13 +620,12 @@ async def test_set_target_temperature( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_humidity( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -562,15 +636,21 @@ async def test_set_target_humidity( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"temperature_state_topic": "temperature-state"},), + ) + ], +) async def test_set_target_temperature_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_state_topic"] = "temperature-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") is None @@ -588,16 +668,21 @@ async def test_set_target_temperature_pessimistic( assert state.attributes.get("temperature") == 1701 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"temperature_state_topic": "temperature-state", "optimistic": True},), + ) + ], +) async def test_set_target_temperature_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature optimistic.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_state_topic"] = "temperature-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -615,13 +700,12 @@ async def test_set_target_temperature_optimistic( assert state.attributes.get("temperature") == 18 +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_temperature_low_high( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, target_temp_low=20, target_temp_high=23, entity_id=ENTITY_CLIMATE @@ -633,16 +717,26 @@ async def test_set_target_temperature_low_high( mqtt_mock.async_publish.assert_any_call("temperature-high-topic", "23.0", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_low_state_topic": "temperature-low-state", + "temperature_high_state_topic": "temperature-high-state", + }, + ), + ) + ], +) async def test_set_target_temperature_low_highpessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_low_state_topic"] = "temperature-low-state" - config["climate"]["temperature_high_state_topic"] = "temperature-high-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("target_temp_low") is None @@ -673,17 +767,27 @@ async def test_set_target_temperature_low_highpessimistic( assert state.attributes.get("target_temp_high") == 1703 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_low_state_topic": "temperature-low-state", + "temperature_high_state_topic": "temperature-high-state", + "optimistic": True, + }, + ), + ) + ], +) async def test_set_target_temperature_low_high_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature optimistic.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["optimistic"] = True - config["climate"]["temperature_low_state_topic"] = "temperature-low-state" - config["climate"]["temperature_high_state_topic"] = "temperature-high-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("target_temp_low") == 21 @@ -714,16 +818,21 @@ async def test_set_target_temperature_low_high_optimistic( assert state.attributes.get("target_temp_high") == 25 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"target_humidity_state_topic": "humidity-state", "optimistic": True},), + ) + ], +) async def test_set_target_humidity_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity optimistic.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["target_humidity_state_topic"] = "humidity-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -740,15 +849,21 @@ async def test_set_target_humidity_optimistic( assert state.attributes.get("humidity") == 53 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"target_humidity_state_topic": "humidity-state"},), + ) + ], +) async def test_set_target_humidity_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["target_humidity_state_topic"] = "humidity-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -765,45 +880,63 @@ async def test_set_target_humidity_pessimistic( assert state.attributes.get("humidity") == 80 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"current_temperature_topic": "current_temperature"},), + ) + ], +) async def test_receive_mqtt_temperature( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test getting the current temperature via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["current_temperature_topic"] = "current_temperature" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "current_temperature", "47") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("current_temperature") == 47 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"current_humidity_topic": "current_humidity"},), + ) + ], +) async def test_receive_mqtt_humidity( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test getting the current humidity via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["current_humidity_topic"] = "current_humidity" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "current_humidity", "35") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("current_humidity") == 35 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"target_humidity_state_topic": "humidity-state"},), + ) + ], +) async def test_handle_target_humidity_received( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["target_humidity_state_topic"] = "humidity-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -814,15 +947,15 @@ async def test_handle_target_humidity_received( assert state.attributes.get("humidity") == 65 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"action_topic": "action"},))], +) async def test_handle_action_received( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test getting the action received via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["action_topic"] = "action" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # Cycle through valid modes and also check for wrong input such as "None" (str(None)) async_fire_mqtt_message(hass, "action", "None") @@ -839,16 +972,14 @@ async def test_handle_action_received( assert hvac_action == action +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_preset_mode_optimistic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -889,18 +1020,23 @@ async def test_set_preset_mode_optimistic( assert "'invalid' is not a valid preset mode" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"preset_mode_state_topic": "preset-mode-state", "optimistic": True},), + ) + ], +) async def test_set_preset_mode_explicit_optimistic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["optimistic"] = True - config["climate"]["preset_mode_state_topic"] = "preset-mode-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -941,17 +1077,23 @@ async def test_set_preset_mode_explicit_optimistic( assert "'invalid' is not a valid preset mode" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"preset_mode_state_topic": "preset-mode-state"},), + ) + ], +) async def test_set_preset_mode_pessimistic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["preset_mode_state_topic"] = "preset-mode-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -990,15 +1132,19 @@ async def test_set_preset_mode_pessimistic( assert state.attributes.get("preset_mode") == "home" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"aux_state_topic": "aux-state"},) + ) + ], +) async def test_set_aux_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of the aux heating in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["aux_state_topic"] = "aux-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" @@ -1020,13 +1166,12 @@ async def test_set_aux_pessimistic( assert state.attributes.get("aux_heat") == "off" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_aux( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of the aux heating.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" @@ -1080,21 +1225,30 @@ async def test_custom_availability_payload( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_low_state_topic": "temperature-state", + "temperature_high_state_topic": "temperature-state", + "temperature_low_state_template": "{{ value_json.temp_low }}", + "temperature_high_state_template": "{{ value_json.temp_high }}", + }, + ), + ) + ], +) async def test_get_target_temperature_low_high_with_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test getting temperature high/low with templates.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_low_state_topic"] = "temperature-state" - config["climate"]["temperature_high_state_topic"] = "temperature-state" - config["climate"]["temperature_low_state_template"] = "{{ value_json.temp_low }}" - config["climate"]["temperature_high_state_template"] = "{{ value_json.temp_high }}" - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) @@ -1148,34 +1302,60 @@ async def test_get_target_temperature_low_high_with_templates( assert "Could not parse temperature_high_state_template from" not in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "target_humidity_command_topic": "humidity-topic", + "temperature_command_topic": "temperature-topic", + "temperature_low_command_topic": "temperature-low-topic", + "temperature_high_command_topic": "temperature-high-topic", + "fan_mode_command_topic": "fan-mode-topic", + "swing_mode_command_topic": "swing-mode-topic", + "aux_command_topic": "aux-topic", + "preset_mode_command_topic": "preset-mode-topic", + "preset_modes": [ + "eco", + "away", + "boost", + "comfort", + "home", + "sleep", + "activity", + ], + # By default, just unquote the JSON-strings + "value_template": "{{ value_json }}", + "action_template": "{{ value_json }}", + # Rendering to a bool for aux heat + "aux_state_template": "{{ value == 'switchmeon' }}", + # Rendering preset_mode + "preset_mode_value_template": "{{ value_json.attribute }}", + "action_topic": "action", + "mode_state_topic": "mode-state", + "fan_mode_state_topic": "fan-state", + "swing_mode_state_topic": "swing-state", + "temperature_state_topic": "temperature-state", + "target_humidity_state_topic": "humidity-state", + "aux_state_topic": "aux-state", + "current_temperature_topic": "current-temperature", + "current_humidity_topic": "current-humidity", + "preset_mode_state_topic": "current-preset-mode", + } + } + } + ], +) async def test_get_with_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test getting various attributes with templates.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - # By default, just unquote the JSON-strings - config["climate"]["value_template"] = "{{ value_json }}" - config["climate"]["action_template"] = "{{ value_json }}" - # Rendering to a bool for aux heat - config["climate"]["aux_state_template"] = "{{ value == 'switchmeon' }}" - # Rendering preset_mode - config["climate"]["preset_mode_value_template"] = "{{ value_json.attribute }}" - - config["climate"]["action_topic"] = "action" - config["climate"]["mode_state_topic"] = "mode-state" - config["climate"]["fan_mode_state_topic"] = "fan-state" - config["climate"]["swing_mode_state_topic"] = "swing-state" - config["climate"]["temperature_state_topic"] = "temperature-state" - config["climate"]["target_humidity_state_topic"] = "humidity-state" - config["climate"]["aux_state_topic"] = "aux-state" - config["climate"]["current_temperature_topic"] = "current-temperature" - config["climate"]["current_humidity_topic"] = "current-humidity" - config["climate"]["preset_mode_state_topic"] = "current-preset-mode" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # Operation Mode state = hass.states.get(ENTITY_CLIMATE) @@ -1286,26 +1466,52 @@ async def test_get_with_templates( assert state.attributes.get("hvac_action") == "cooling" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "target_humidity_command_topic": "humidity-topic", + "temperature_command_topic": "temperature-topic", + "temperature_low_command_topic": "temperature-low-topic", + "temperature_high_command_topic": "temperature-high-topic", + "fan_mode_command_topic": "fan-mode-topic", + "swing_mode_command_topic": "swing-mode-topic", + "aux_command_topic": "aux-topic", + "preset_mode_command_topic": "preset-mode-topic", + "preset_modes": [ + "eco", + "away", + "boost", + "comfort", + "home", + "sleep", + "activity", + ], + # Create simple templates + "fan_mode_command_template": "fan_mode: {{ value }}", + "preset_mode_command_template": "preset_mode: {{ value }}", + "mode_command_template": "mode: {{ value }}", + "swing_mode_command_template": "swing_mode: {{ value }}", + "temperature_command_template": "temp: {{ value }}", + "temperature_high_command_template": "temp_hi: {{ value }}", + "temperature_low_command_template": "temp_lo: {{ value }}", + "target_humidity_command_template": "humidity: {{ value }}", + } + } + } + ], +) async def test_set_and_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting various attributes with templates.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - # Create simple templates - config["climate"]["fan_mode_command_template"] = "fan_mode: {{ value }}" - config["climate"]["preset_mode_command_template"] = "preset_mode: {{ value }}" - config["climate"]["mode_command_template"] = "mode: {{ value }}" - config["climate"]["swing_mode_command_template"] = "swing_mode: {{ value }}" - config["climate"]["temperature_command_template"] = "temp: {{ value }}" - config["climate"]["temperature_high_command_template"] = "temp_hi: {{ value }}" - config["climate"]["temperature_low_command_template"] = "temp_lo: {{ value }}" - config["climate"]["target_humidity_command_template"] = "humidity: {{ value }}" - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() # Fan Mode await common.async_set_fan_mode(hass, "high", ENTITY_CLIMATE) @@ -1378,16 +1584,15 @@ async def test_set_and_templates( assert state.attributes.get("humidity") == 82 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"min_temp": 26},))], +) async def test_min_temp_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom min temp.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["min_temp"] = 26 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) min_temp = state.attributes.get("min_temp") @@ -1396,16 +1601,15 @@ async def test_min_temp_custom( assert state.attributes.get("min_temp") == 26 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"max_temp": 60},))], +) async def test_max_temp_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom max temp.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["max_temp"] = 60 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) max_temp = state.attributes.get("max_temp") @@ -1414,16 +1618,15 @@ async def test_max_temp_custom( assert max_temp == 60 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"min_humidity": 42},))], +) async def test_min_humidity_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom min humidity.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["min_humidity"] = 42 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) min_humidity = state.attributes.get("min_humidity") @@ -1432,16 +1635,15 @@ async def test_min_humidity_custom( assert state.attributes.get("min_humidity") == 42 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"max_humidity": 58},))], +) async def test_max_humidity_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom max humidity.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["max_humidity"] = 58 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) max_humidity = state.attributes.get("max_humidity") @@ -1450,16 +1652,15 @@ async def test_max_humidity_custom( assert max_humidity == 58 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"temp_step": 0.01},))], +) async def test_temp_step_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom temp step.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temp_step"] = 0.01 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) temp_step = state.attributes.get("target_temp_step") @@ -1468,17 +1669,26 @@ async def test_temp_step_custom( assert temp_step == 0.01 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ) + ], +) async def test_temperature_unit( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting temperature unit converts temperature values.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_unit"] = "F" - config["climate"]["current_temperature_topic"] = "current_temperature" - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "current_temperature", "77") @@ -1783,13 +1993,12 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_precision_default( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting precision to tenths works as intended.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1799,15 +2008,15 @@ async def test_precision_default( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"precision": 0.5},))], +) async def test_precision_halves( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting precision to halves works as intended.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["precision"] = 0.5 - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1817,15 +2026,15 @@ async def test_precision_halves( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"precision": 1.0},))], +) async def test_precision_whole( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting precision to whole works as intended.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["precision"] = 1.0 - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1950,61 +2159,80 @@ async def test_publishing_with_custom_encoding( @pytest.mark.parametrize( - ("config", "valid"), + ("hass_config", "valid"), [ ( { - "name": "test_valid_humidity_min_max", - "min_humidity": 20, - "max_humidity": 80, + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_valid_humidity_min_max", + "min_humidity": 20, + "max_humidity": 80, + }, + } }, True, ), ( { - "name": "test_invalid_humidity_min_max_1", - "min_humidity": 0, - "max_humidity": 101, + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_invalid_humidity_min_max_1", + "min_humidity": 0, + "max_humidity": 101, + }, + } }, False, ), ( { - "name": "test_invalid_humidity_min_max_2", - "max_humidity": 20, - "min_humidity": 40, + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_invalid_humidity_min_max_2", + "max_humidity": 20, + "min_humidity": 40, + }, + } }, False, ), ( { - "name": "test_valid_humidity_state", - "target_humidity_state_topic": "humidity-state", - "target_humidity_command_topic": "humidity-command", + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_valid_humidity_state", + "target_humidity_state_topic": "humidity-state", + "target_humidity_command_topic": "humidity-command", + }, + } }, True, ), ( { - "name": "test_invalid_humidity_state", - "target_humidity_state_topic": "humidity-state", + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_invalid_humidity_state", + "target_humidity_state_topic": "humidity-state", + }, + } }, False, ), ], ) async def test_humidity_configuration_validity( - hass: HomeAssistant, config, valid + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + valid: bool, ) -> None: """Test the validity of humidity configurations.""" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {climate.DOMAIN: config}}, - ) - is valid - ) + if valid: + await mqtt_mock_entry_no_yaml_config() + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() async def test_reloadable( From 44add1dc1101d91e641489bfa8c1bae13fb6f655 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:14:08 +0100 Subject: [PATCH 0088/1165] Prepare MQTT platform tests part3 (#90106) * Tests cover * Tests fan --- tests/components/mqtt/test_cover.py | 1557 ++++++++++++++------------- tests/components/mqtt/test_fan.py | 551 +++++----- 2 files changed, 1133 insertions(+), 975 deletions(-) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index a09edcd25e..dd28d0919b 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -45,7 +45,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -91,13 +90,9 @@ def cover_platform_only(): yield -async def test_state_via_state_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -110,10 +105,14 @@ async def test_state_via_state_topic( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_state_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -130,13 +129,9 @@ async def test_state_via_state_topic( assert state.state == STATE_OPEN -async def test_opening_and_closing_state_via_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling opening and closing state via a custom payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -151,10 +146,14 @@ async def test_opening_and_closing_state_via_custom_state_payload( "state_closing": "--43", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_opening_and_closing_state_via_custom_state_payload( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling opening and closing state via a custom payload.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -176,13 +175,9 @@ async def test_opening_and_closing_state_via_custom_state_payload( assert state.state == STATE_CLOSED -async def test_open_closed_state_from_position_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the state after setting the position using optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -196,10 +191,14 @@ async def test_open_closed_state_from_position_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_open_closed_state_from_position_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the state after setting the position using optimistic mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -227,13 +226,9 @@ async def test_open_closed_state_from_position_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_position_via_position_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -248,10 +243,14 @@ async def test_position_via_position_topic( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -268,13 +267,9 @@ async def test_position_via_position_topic( assert state.state == STATE_OPEN -async def test_state_via_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -290,10 +285,14 @@ async def test_state_via_template( {% endif %}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -309,13 +308,9 @@ async def test_state_via_template( assert state.state == STATE_CLOSED -async def test_state_via_template_and_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -331,10 +326,14 @@ async def test_state_via_template_and_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_template_and_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -352,15 +351,9 @@ async def test_state_via_template_and_entity_id( assert state.state == STATE_CLOSED -async def test_state_via_template_with_json_value( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic with JSON value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -371,10 +364,16 @@ async def test_state_via_template_with_json_value( "value_template": "{{ value_json.Var1 }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_template_with_json_value( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic with JSON value.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -397,13 +396,9 @@ async def test_state_via_template_with_json_value( ) in caplog.text -async def test_position_via_template_and_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -419,10 +414,14 @@ async def test_position_via_template_and_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_template_and_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -443,30 +442,77 @@ async def test_position_via_template_and_entity_id( @pytest.mark.parametrize( - ("config", "assumed_state"), + ("hass_config", "assumed_state"), [ - ({"command_topic": "abc"}, True), - ({"command_topic": "abc", "state_topic": "abc"}, False), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: {"name": "test", "qos": 0, "command_topic": "abc"} + } + }, + True, + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "command_topic": "abc", + "state_topic": "abc", + } + } + }, + False, + ), # ({"set_position_topic": "abc"}, True), - not a valid configuration - ({"set_position_topic": "abc", "position_topic": "abc"}, False), - ({"tilt_command_topic": "abc"}, True), - ({"tilt_command_topic": "abc", "tilt_status_topic": "abc"}, False), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "set_position_topic": "abc", + "position_topic": "abc", + } + } + }, + False, + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "tilt_command_topic": "abc", + } + } + }, + True, + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "tilt_command_topic": "abc", + "tilt_status_topic": "abc", + } + } + }, + False, + ), ], ) async def test_optimistic_flag( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - config, - assumed_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + assumed_state: bool, ) -> None: """Test assumed_state is set correctly.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {cover.DOMAIN: {**config, "name": "test", "qos": 0}}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -476,13 +522,9 @@ async def test_optimistic_flag( assert ATTR_ASSUMED_STATE not in state.attributes -async def test_optimistic_state_change( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test changing state optimistically.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -491,10 +533,14 @@ async def test_optimistic_state_change( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_optimistic_state_change( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test changing state optimistically.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -536,13 +582,9 @@ async def test_optimistic_state_change( assert state.state == STATE_CLOSED -async def test_optimistic_state_change_with_position( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test changing state optimistically.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -553,10 +595,14 @@ async def test_optimistic_state_change_with_position( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_optimistic_state_change_with_position( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test changing state optimistically.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -603,13 +649,9 @@ async def test_optimistic_state_change_with_position( assert state.attributes.get(ATTR_CURRENT_POSITION) == 0 -async def test_send_open_cover_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of open_cover.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -619,10 +661,14 @@ async def test_send_open_cover_command( "qos": 2, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_send_open_cover_command( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of open_cover.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -636,13 +682,9 @@ async def test_send_open_cover_command( assert state.state == STATE_UNKNOWN -async def test_send_close_cover_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of close_cover.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -652,10 +694,14 @@ async def test_send_close_cover_command( "qos": 2, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_send_close_cover_command( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of close_cover.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -669,13 +715,9 @@ async def test_send_close_cover_command( assert state.state == STATE_UNKNOWN -async def test_send_stop__cover_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of stop_cover.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -685,10 +727,14 @@ async def test_send_stop__cover_command( "qos": 2, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_send_stop_cover_command( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of stop_cover.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -702,13 +748,9 @@ async def test_send_stop__cover_command( assert state.state == STATE_UNKNOWN -async def test_current_cover_position( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the current cover position.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -722,10 +764,14 @@ async def test_current_cover_position( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_current_cover_position( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the current cover position.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -757,13 +803,9 @@ async def test_current_cover_position( assert current_cover_position == 100 -async def test_current_cover_position_inverted( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the current cover position.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -777,10 +819,14 @@ async def test_current_cover_position_inverted( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_current_cover_position_inverted( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the current cover position.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -823,13 +869,9 @@ async def test_current_cover_position_inverted( assert hass.states.get("cover.test").state == STATE_CLOSED -async def test_optimistic_position( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test optimistic position is not supported.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -838,21 +880,26 @@ async def test_optimistic_position( "set_position_topic": "set-position-topic", } } - }, - ) + } + ], +) +async def test_optimistic_position( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic position is not supported.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: 'set_position_topic' must be set together with 'position_topic'" in caplog.text ) -async def test_position_update( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test cover position update from received MQTT message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -867,10 +914,14 @@ async def test_position_update( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_update( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test cover position update from received MQTT message.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -888,39 +939,58 @@ async def test_position_update( @pytest.mark.parametrize( - ("pos_template", "pos_call", "pos_message"), - [("{{position-1}}", 43, "42"), ("{{100-62}}", 100, "38")], + ("hass_config", "pos_call", "pos_message"), + [ + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "position_topic": "get-position-topic", + "command_topic": "command-topic", + "position_open": 100, + "position_closed": 0, + "set_position_topic": "set-position-topic", + "set_position_template": "{{position-1}}", + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + } + } + }, + 43, + "42", + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "position_topic": "get-position-topic", + "command_topic": "command-topic", + "position_open": 100, + "position_closed": 0, + "set_position_topic": "set-position-topic", + "set_position_template": "{{100-62}}", + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + } + } + }, + 100, + "38", + ), + ], ) async def test_set_position_templated( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - pos_template, - pos_call, - pos_message, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + pos_call: int, + pos_message: str, ) -> None: """Test setting cover position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - cover.DOMAIN: { - "name": "test", - "position_topic": "get-position-topic", - "command_topic": "command-topic", - "position_open": 100, - "position_closed": 0, - "set_position_topic": "set-position-topic", - "set_position_template": pos_template, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -934,13 +1004,9 @@ async def test_set_position_templated( ) -async def test_set_position_templated_and_attributes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover position via template and using entities attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -965,10 +1031,14 @@ async def test_set_position_templated_and_attributes( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_position_templated_and_attributes( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover position via template and using entities attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -980,13 +1050,9 @@ async def test_set_position_templated_and_attributes( mqtt_mock.async_publish.assert_called_once_with("set-position-topic", "5", 0, False) -async def test_set_tilt_templated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover tilt position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1004,10 +1070,14 @@ async def test_set_tilt_templated( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_tilt_templated( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover tilt position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1021,13 +1091,9 @@ async def test_set_tilt_templated( ) -async def test_set_tilt_templated_and_attributes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover tilt position via template and using entities attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1049,10 +1115,14 @@ async def test_set_tilt_templated_and_attributes( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_tilt_templated_and_attributes( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover tilt position via template and using entities attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1111,13 +1181,9 @@ async def test_set_tilt_templated_and_attributes( ) -async def test_set_position_untemplated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1130,10 +1196,14 @@ async def test_set_position_untemplated( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_position_untemplated( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1145,13 +1215,9 @@ async def test_set_position_untemplated( mqtt_mock.async_publish.assert_called_once_with("position-topic", "62", 0, False) -async def test_set_position_untemplated_custom_percentage_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1166,10 +1232,14 @@ async def test_set_position_untemplated_custom_percentage_range( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_position_untemplated_custom_percentage_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1181,13 +1251,9 @@ async def test_set_position_untemplated_custom_percentage_range( mqtt_mock.async_publish.assert_called_once_with("position-topic", "62", 0, False) -async def test_no_command_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1200,21 +1266,21 @@ async def test_no_command_topic( "tilt_status_topic": "tilt-status", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_command_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no command topic.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 240 -async def test_no_payload_close( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no close payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1226,21 +1292,21 @@ async def test_no_payload_close( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_payload_close( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no close payload.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 9 -async def test_no_payload_open( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no open payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1252,21 +1318,21 @@ async def test_no_payload_open( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_payload_open( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no open payload.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 10 -async def test_no_payload_stop( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no stop payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1278,21 +1344,21 @@ async def test_no_payload_stop( "payload_stop": None, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_payload_stop( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no stop payload.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 3 -async def test_with_command_topic_and_tilt( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with command topic and tilt config.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1306,21 +1372,21 @@ async def test_with_command_topic_and_tilt( "tilt_status_topic": "tilt-status", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_with_command_topic_and_tilt( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with command topic and tilt config.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 251 -async def test_tilt_defaults( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the defaults.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1335,23 +1401,23 @@ async def test_tilt_defaults( "tilt_status_topic": "tilt-status", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_defaults( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the defaults.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes # Tilt position is not yet known assert ATTR_CURRENT_TILT_POSITION not in state_attributes_dict -async def test_tilt_via_invocation_defaults( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt defaults on close/open.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1366,10 +1432,15 @@ async def test_tilt_via_invocation_defaults( "tilt_status_topic": "tilt-status-topic", } } - }, - ) + } + ], +) +async def test_tilt_via_invocation_defaults( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt defaults on close/open.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1431,13 +1502,9 @@ async def test_tilt_via_invocation_defaults( mqtt_mock.async_publish.assert_called_once_with("tilt-command-topic", "0", 0, False) -async def test_tilt_given_value( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilting to a given value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1454,10 +1521,14 @@ async def test_tilt_given_value( "tilt_closed_value": 25, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_given_value( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilting to a given value.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1523,13 +1594,9 @@ async def test_tilt_given_value( ) -async def test_tilt_given_value_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilting to a given value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1547,10 +1614,14 @@ async def test_tilt_given_value_optimistic( "tilt_optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_given_value_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilting to a given value.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1603,13 +1674,9 @@ async def test_tilt_given_value_optimistic( ) -async def test_tilt_given_value_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilting to a given value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1629,10 +1696,14 @@ async def test_tilt_given_value_altered_range( "tilt_optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_given_value_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilting to a given value.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1683,13 +1754,9 @@ async def test_tilt_given_value_altered_range( ) -async def test_tilt_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt by updating status via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1704,10 +1771,14 @@ async def test_tilt_via_topic( "tilt_status_topic": "tilt-status-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt by updating status via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1724,13 +1795,9 @@ async def test_tilt_via_topic( assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt by updating status via MQTT and template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1748,10 +1815,14 @@ async def test_tilt_via_topic_template( "tilt_closed_value": 125, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt by updating status via MQTT and template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "99") @@ -1768,15 +1839,9 @@ async def test_tilt_via_topic_template( assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template_json_value( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test tilt by updating status via MQTT and template with JSON value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1794,10 +1859,16 @@ async def test_tilt_via_topic_template_json_value( "tilt_closed_value": 125, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_template_json_value( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test tilt by updating status via MQTT and template with JSON value.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", '{"Var1": 9, "Var2": 30}') @@ -1820,13 +1891,9 @@ async def test_tilt_via_topic_template_json_value( ) in caplog.text -async def test_tilt_via_topic_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt status via MQTT with altered tilt range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1843,10 +1910,14 @@ async def test_tilt_via_topic_altered_range( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt status via MQTT with altered tilt range.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1870,15 +1941,9 @@ async def test_tilt_via_topic_altered_range( assert current_cover_tilt_position == 50 -async def test_tilt_status_out_of_range_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test tilt status via MQTT tilt out of range warning message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1895,10 +1960,16 @@ async def test_tilt_status_out_of_range_warning( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_status_out_of_range_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test tilt status via MQTT tilt out of range warning message.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "60") @@ -1907,15 +1978,9 @@ async def test_tilt_status_out_of_range_warning( ) in caplog.text -async def test_tilt_status_not_numeric_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test tilt status via MQTT tilt not numeric warning message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1932,23 +1997,25 @@ async def test_tilt_status_not_numeric_warning( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_status_not_numeric_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test tilt status via MQTT tilt not numeric warning message.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "abc") assert ("Payload 'abc' is not numeric") in caplog.text -async def test_tilt_via_topic_altered_range_inverted( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt status via MQTT with altered tilt range and inverted tilt position.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1965,10 +2032,14 @@ async def test_tilt_via_topic_altered_range_inverted( "tilt_max": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_altered_range_inverted( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt status via MQTT with altered tilt range and inverted tilt position.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1992,13 +2063,9 @@ async def test_tilt_via_topic_altered_range_inverted( assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt status via MQTT and template with altered tilt range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2018,10 +2085,14 @@ async def test_tilt_via_topic_template_altered_range( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_template_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt status via MQTT and template with altered tilt range.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "99") @@ -2045,13 +2116,9 @@ async def test_tilt_via_topic_template_altered_range( assert current_cover_tilt_position == 50 -async def test_tilt_position( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt via method invocation.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2066,10 +2133,14 @@ async def test_tilt_position( "tilt_status_topic": "tilt-status-topic", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_position( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt via method invocation.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -2083,13 +2154,9 @@ async def test_tilt_position( ) -async def test_tilt_position_templated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2105,10 +2172,14 @@ async def test_tilt_position_templated( "tilt_command_template": "{{100-32}}", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_position_templated( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -2122,13 +2193,9 @@ async def test_tilt_position_templated( ) -async def test_tilt_position_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt via method invocation with altered range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2147,10 +2214,14 @@ async def test_tilt_position_altered_range( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_position_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt via method invocation with altered range.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -2164,6 +2235,7 @@ async def test_tilt_position_altered_range( ) +@pytest.mark.parametrize("hass_config", []) async def test_find_percentage_in_range_defaults(hass: HomeAssistant) -> None: """Test find percentage in range with default range.""" mqtt_cover = MqttCover( @@ -2546,13 +2618,9 @@ async def test_custom_availability_payload( ) -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of a valid device class.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2561,22 +2629,22 @@ async def test_valid_device_class( "state_topic": "test-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of a valid device class.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.attributes.get("device_class") == "garage" -async def test_invalid_device_class( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test the setting of an invalid device class.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2585,8 +2653,17 @@ async def test_invalid_device_class( "state_topic": "test-topic", } } - }, - ) + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of an invalid device class.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert "Invalid config for [mqtt]: expected CoverDeviceClass" in caplog.text @@ -2821,13 +2898,9 @@ async def test_entity_debug_info_message( ) -async def test_state_and_position_topics_state_not_set_via_position_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state is not set via position topic when both state and position topics are set.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2842,10 +2915,14 @@ async def test_state_and_position_topics_state_not_set_via_position_topic( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_and_position_topics_state_not_set_via_position_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state is not set via position topic when both state and position topics are set.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -2882,13 +2959,9 @@ async def test_state_and_position_topics_state_not_set_via_position_topic( assert state.state == STATE_CLOSED -async def test_set_state_via_position_using_stopped_state( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via position topic using stopped state.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2904,10 +2977,14 @@ async def test_set_state_via_position_using_stopped_state( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_state_via_position_using_stopped_state( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via position topic using stopped state.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -2939,13 +3016,9 @@ async def test_set_state_via_position_using_stopped_state( assert state.state == STATE_OPEN -async def test_position_via_position_topic_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2957,10 +3030,14 @@ async def test_position_via_position_topic_template( "position_template": "{{ (value | multiply(0.01)) | int }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "99") @@ -2977,15 +3054,9 @@ async def test_position_via_position_topic_template( assert current_cover_position_position == 50 -async def test_position_via_position_topic_template_json_value( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test position by updating status via position template with a JSON value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2997,10 +3068,16 @@ async def test_position_via_position_topic_template_json_value( "position_template": "{{ value_json.Var1 }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_json_value( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test position by updating status via position template with a JSON value.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", '{"Var1": 9, "Var2": 60}') @@ -3023,13 +3100,9 @@ async def test_position_via_position_topic_template_json_value( ) in caplog.text -async def test_position_template_with_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3046,10 +3119,14 @@ async def test_position_template_with_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_template_with_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "10") @@ -3066,13 +3143,9 @@ async def test_position_template_with_entity_id( assert current_cover_position_position == 20 -async def test_position_via_position_topic_template_return_json( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template and returning json.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3084,10 +3157,14 @@ async def test_position_via_position_topic_template_return_json( "position_template": '{{ {"position" : value} | tojson }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_return_json( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template and returning json.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -3097,15 +3174,9 @@ async def test_position_via_position_topic_template_return_json( assert current_cover_position_position == 55 -async def test_position_via_position_topic_template_return_json_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test position by updating status via position template returning json without position attribute.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3117,10 +3188,16 @@ async def test_position_via_position_topic_template_return_json_warning( "position_template": '{{ {"pos" : value} | tojson }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_return_json_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test position by updating status via position template returning json without position attribute.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -3130,13 +3207,9 @@ async def test_position_via_position_topic_template_return_json_warning( ) -async def test_position_and_tilt_via_position_topic_template_return_json( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position and tilt by updating the position via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3149,10 +3222,14 @@ async def test_position_and_tilt_via_position_topic_template_return_json( {{ {"position" : value, "tilt_position" : (value | int / 2)| int } | tojson }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_and_tilt_via_position_topic_template_return_json( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position and tilt by updating the position via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "0") @@ -3174,13 +3251,9 @@ async def test_position_and_tilt_via_position_topic_template_return_json( assert current_cover_position == 99 and current_tilt_position == 49 -async def test_position_via_position_topic_template_all_variables( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3203,10 +3276,14 @@ async def test_position_via_position_topic_template_all_variables( {% endif %}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_all_variables( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "0") @@ -3222,13 +3299,9 @@ async def test_position_via_position_topic_template_all_variables( assert current_cover_position == 100 -async def test_set_state_via_stopped_state_no_position_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via stopped state when no position topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3244,10 +3317,14 @@ async def test_set_state_via_stopped_state_no_position_topic( "optimistic": False, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_state_via_stopped_state_no_position_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via stopped state when no position topic.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "state-topic", "OPEN") @@ -3275,15 +3352,9 @@ async def test_set_state_via_stopped_state_no_position_topic( assert state.state == STATE_CLOSED -async def test_position_via_position_topic_template_return_invalid_json( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test position by updating status via position template and returning invalid json.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3295,23 +3366,25 @@ async def test_position_via_position_topic_template_return_invalid_json( "position_template": '{{ {"position" : invalid_json} }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_return_invalid_json( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test position by updating status via position template and returning invalid json.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") assert ("Payload '{'position': Undefined}' is not numeric") in caplog.text -async def test_set_position_topic_without_get_position_topic_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when set_position_topic is used without position_topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3321,21 +3394,25 @@ async def test_set_position_topic_without_get_position_topic_error( "value_template": "{{100-62}}", } } - }, - ) + } + ], +) +async def test_set_position_topic_without_get_position_topic_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when set_position_topic is used without position_topic.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_SET_POSITION_TOPIC}' must be set together with '{CONF_GET_POSITION_TOPIC}'." ) in caplog.text -async def test_value_template_without_state_topic_error( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error when value_template is used and state_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3344,20 +3421,25 @@ async def test_value_template_without_state_topic_error( "value_template": "{{100-62}}", } } - }, - ) + } + ], +) +async def test_value_template_without_state_topic_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when value_template is used and state_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_VALUE_TEMPLATE}' must be set together with '{CONF_STATE_TOPIC}'." ) in caplog.text -async def test_position_template_without_position_topic_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when position_template is used and position_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3366,22 +3448,26 @@ async def test_position_template_without_position_topic_error( "position_template": "{{100-52}}", } } - }, - ) + } + ], +) +async def test_position_template_without_position_topic_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when position_template is used and position_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_GET_POSITION_TEMPLATE}' must be set together with '{CONF_GET_POSITION_TOPIC}'." in caplog.text ) -async def test_set_position_template_without_set_position_topic( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error when set_position_template is used and set_position_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3390,21 +3476,26 @@ async def test_set_position_template_without_set_position_topic( "set_position_template": "{{100-42}}", } } - }, - ) + } + ], +) +async def test_set_position_template_without_set_position_topic( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when set_position_template is used and set_position_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_SET_POSITION_TEMPLATE}' must be set together with '{CONF_SET_POSITION_TOPIC}'." in caplog.text ) -async def test_tilt_command_template_without_tilt_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when tilt_command_template is used and tilt_command_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3413,21 +3504,26 @@ async def test_tilt_command_template_without_tilt_command_topic( "tilt_command_template": "{{100-32}}", } } - }, - ) + } + ], +) +async def test_tilt_command_template_without_tilt_command_topic( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when tilt_command_template is used and tilt_command_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_TILT_COMMAND_TEMPLATE}' must be set together with '{CONF_TILT_COMMAND_TOPIC}'." in caplog.text ) -async def test_tilt_status_template_without_tilt_status_topic_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when tilt_status_template is used and tilt_status_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3436,8 +3532,17 @@ async def test_tilt_status_template_without_tilt_status_topic_topic( "tilt_status_template": "{{100-22}}", } } - }, - ) + } + ], +) +async def test_tilt_status_template_without_tilt_status_topic_topic( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when tilt_status_template is used and tilt_status_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_TILT_STATUS_TEMPLATE}' must be set together with '{CONF_TILT_STATUS_TOPIC}'." in caplog.text diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 41ff43aba8..9882b9102e 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -32,7 +32,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -85,30 +84,24 @@ def fan_platform_only(): yield +@pytest.mark.parametrize("hass_config", [{mqtt.DOMAIN: {fan.DOMAIN: {"name": "test"}}}]) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test if command fails with command topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {fan.DOMAIN: {"name": "test"}}}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['fan'][0]['command_topic']" in caplog.text ) -async def test_controlling_state_via_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -139,10 +132,16 @@ async def test_controlling_state_via_topic( "payload_reset_preset_mode": "rEset_preset_mode", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -224,15 +223,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic_with_different_speed_range( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic using an alternate speed range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: [ @@ -262,10 +255,16 @@ async def test_controlling_state_via_topic_with_different_speed_range( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_with_different_speed_range( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic using an alternate speed range.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "percentage-state-topic1", "100") state = hass.states.get("fan.test1") @@ -288,15 +287,9 @@ async def test_controlling_state_via_topic_with_different_speed_range( caplog.clear() -async def test_controlling_state_via_topic_no_percentage_topics( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic without percentage topics.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -314,10 +307,16 @@ async def test_controlling_state_via_topic_no_percentage_topics( ], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_no_percentage_topics( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic without percentage topics.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -347,15 +346,9 @@ async def test_controlling_state_via_topic_no_percentage_topics( caplog.clear() -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message (percentage mode).""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -384,10 +377,16 @@ async def test_controlling_state_via_topic_and_json_message( "speed_range_max": 100, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message (percentage mode).""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -454,15 +453,9 @@ async def test_controlling_state_via_topic_and_json_message( assert state.attributes.get("preset_mode") is None -async def test_controlling_state_via_topic_and_json_message_shared_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message using a shared topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -491,10 +484,16 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( "speed_range_max": 100, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message_shared_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message using a shared topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -544,15 +543,9 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( caplog.clear() -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -572,10 +565,15 @@ async def test_sending_mqtt_commands_and_optimistic( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -671,13 +669,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_with_alternate_speed_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic using an alternate speed range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: [ @@ -707,10 +701,14 @@ async def test_sending_mqtt_commands_with_alternate_speed_range( }, ] } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_with_alternate_speed_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic using an alternate speed range.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_percentage(hass, "fan.test1", 0) mqtt_mock.async_publish.assert_called_once_with( @@ -777,15 +775,9 @@ async def test_sending_mqtt_commands_with_alternate_speed_range( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_optimistic_no_legacy( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic without legacy speed command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -800,10 +792,16 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic_no_legacy( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test optimistic mode without state topic without legacy speed command topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -911,15 +909,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( await common.async_turn_on(hass, "fan.test", preset_mode="freaking-high") -async def test_sending_mqtt_command_templates_( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic without legacy speed command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -939,10 +931,15 @@ async def test_sending_mqtt_command_templates_( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_command_templates_( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode without state topic without legacy speed command topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1056,15 +1053,9 @@ async def test_sending_mqtt_command_templates_( await common.async_turn_on(hass, "fan.test", preset_mode="low") -async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic without percentage command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -1080,10 +1071,15 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode without state topic without percentage command topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1120,15 +1116,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_explicit_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode with state topic and turn on attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -1149,10 +1139,15 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_explicit_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode with state topic and turn on attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1395,15 +1390,9 @@ async def test_encoding_subscribable_topics( ) -async def test_attributes( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -1418,10 +1407,16 @@ async def test_attributes( ], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_attributes( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1452,13 +1447,17 @@ async def test_attributes( @pytest.mark.parametrize( - ("name", "config", "success", "features"), + ("name", "hass_config", "success", "features"), [ ( "test1", { - "name": "test1", - "command_topic": "command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test1", + "command_topic": "command-topic", + } + } }, True, 0, @@ -1466,29 +1465,41 @@ async def test_attributes( ( "test2", { - "name": "test2", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test2", + "command_topic": "command-topic", + "oscillation_command_topic": "oscillation-command-topic", + } + } }, True, - fan.SUPPORT_OSCILLATE, + fan.FanEntityFeature.OSCILLATE, ), ( "test3", { - "name": "test3", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test3", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + } + } }, True, - fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.SET_SPEED, ), ( "test4", { - "name": "test4", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test4", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + } + } }, False, None, @@ -1496,100 +1507,136 @@ async def test_attributes( ( "test5", { - "name": "test5", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["eco", "auto"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test5", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["eco", "auto"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test6", { - "name": "test6", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["eco", "smart", "auto"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test6", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["eco", "smart", "auto"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test7", { - "name": "test7", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test7", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + } + } }, True, - fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.SET_SPEED, ), ( "test8", { - "name": "test8", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", - "percentage_command_topic": "percentage-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test8", + "command_topic": "command-topic", + "oscillation_command_topic": "oscillation-command-topic", + "percentage_command_topic": "percentage-command-topic", + } + } }, True, - fan.SUPPORT_OSCILLATE | fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.OSCILLATE | fan.FanEntityFeature.SET_SPEED, ), ( "test9", { - "name": "test9", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["Mode1", "Mode2", "Mode3"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test9", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["Mode1", "Mode2", "Mode3"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test10", { - "name": "test10", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["whoosh", "silent", "auto"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test10", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["whoosh", "silent", "auto"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test11", { - "name": "test11", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["Mode1", "Mode2", "Mode3"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test11", + "command_topic": "command-topic", + "oscillation_command_topic": "oscillation-command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["Mode1", "Mode2", "Mode3"], + } + } }, True, - fan.SUPPORT_PRESET_MODE | fan.SUPPORT_OSCILLATE, + fan.FanEntityFeature.PRESET_MODE | fan.FanEntityFeature.OSCILLATE, ), ( "test12", { - "name": "test12", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", - "speed_range_min": 1, - "speed_range_max": 40, + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test12", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 1, + "speed_range_max": 40, + } + } }, True, - fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.SET_SPEED, ), ( "test13", { - "name": "test13", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", - "speed_range_min": 50, - "speed_range_max": 40, + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test13", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 50, + "speed_range_max": 40, + } + } }, False, None, @@ -1597,11 +1644,15 @@ async def test_attributes( ( "test14", { - "name": "test14", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", - "speed_range_min": 0, - "speed_range_max": 40, + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test14", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 0, + "speed_range_max": 40, + } + } }, False, None, @@ -1609,10 +1660,14 @@ async def test_attributes( ( "test15", { - "name": "test7reset_payload_in_preset_modes_a", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["auto", "smart", "normal", "None"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test7reset_payload_in_preset_modes_a", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["auto", "smart", "normal", "None"], + } + } }, False, None, @@ -1620,39 +1675,37 @@ async def test_attributes( ( "test16", { - "name": "test16", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["whoosh", "silent", "auto", "None"], - "payload_reset_preset_mode": "normal", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test16", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["whoosh", "silent", "auto", "None"], + "payload_reset_preset_mode": "normal", + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ], ) async def test_supported_features( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - name, - config, - success, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + name: str, + success: bool, features, ) -> None: """Test optimistic mode without state topic.""" - - assert ( - await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {fan.DOMAIN: config}} - ) - is success - ) if success: - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(f"fan.{name}") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) From 2c1b59be0eac5b4264a1fb6321bd3359f71325a0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:14:44 +0100 Subject: [PATCH 0089/1165] Prepare MQTT platform tests part4 (#90107) * Tests humidifier * Tests legacy_vacuum --- tests/components/mqtt/test_humidifier.py | 422 +++++++++++--------- tests/components/mqtt/test_legacy_vacuum.py | 390 ++++++++---------- 2 files changed, 398 insertions(+), 414 deletions(-) diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 89afe0a397..0517e8e6a9 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -14,7 +14,6 @@ from homeassistant.components.humidifier import ( SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, ) -from homeassistant.components.mqtt import CONFIG_SCHEMA from homeassistant.components.mqtt.humidifier import ( CONF_MODE_COMMAND_TOPIC, CONF_MODE_STATE_TOPIC, @@ -34,7 +33,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -130,30 +128,26 @@ async def async_set_humidity( await hass.services.async_call(DOMAIN, SERVICE_SET_HUMIDITY, data, blocking=True) +@pytest.mark.parametrize( + "hass_config", [{mqtt.DOMAIN: {humidifier.DOMAIN: {"name": "test"}}}] +) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {humidifier.DOMAIN: {"name": "test"}}}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['humidifier'][0]['command_topic']. Got None" in caplog.text ) -async def test_controlling_state_via_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -178,10 +172,16 @@ async def test_controlling_state_via_topic( "payload_reset_mode": "rEset_mode", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -252,15 +252,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -281,10 +275,17 @@ async def test_controlling_state_via_topic_and_json_message( "mode_state_template": "{{ value_json.val }}", } } - }, - ) + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -343,15 +344,9 @@ async def test_controlling_state_via_topic_and_json_message( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic_and_json_message_shared_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message using a shared topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -372,10 +367,16 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( "mode_state_template": "{{ value_json.mode }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message_shared_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message using a shared topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -422,15 +423,9 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( caplog.clear() -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -447,10 +442,16 @@ async def test_sending_mqtt_commands_and_optimistic( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -521,15 +522,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_command_templates_( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Testing command templates with optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -547,10 +542,16 @@ async def test_sending_mqtt_command_templates_( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_command_templates_( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Testing command templates with optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -621,15 +622,9 @@ async def test_sending_mqtt_command_templates_( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_explicit_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode with state topic and turn on attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -648,10 +643,16 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_explicit_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test optimistic mode with state topic and turn on attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -774,15 +775,9 @@ async def test_encoding_subscribable_topics( ) -async def test_attributes( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -796,10 +791,16 @@ async def test_attributes( ], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_attributes( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -826,105 +827,142 @@ async def test_attributes( @pytest.mark.parametrize( - ("config", "valid"), + ("hass_config", "valid"), [ ( { - "name": "test_valid_1", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_valid_1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, True, ), ( { - "name": "test_valid_2", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "device_class": "humidifier", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_valid_2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "humidifier", + } + } }, True, ), ( { - "name": "test_valid_3", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "device_class": "dehumidifier", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_valid_3", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "dehumidifier", + } + } }, True, ), ( { - "name": "test_invalid_device_class", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "device_class": "notsupporedSpeci@l", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_device_class", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "notsupporedSpeci@l", + } + } }, False, ), ( { - "name": "test_mode_command_without_modes", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_mode_command_without_modes", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + } + } }, False, ), ( { - "name": "test_invalid_humidity_min_max_1", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "min_humidity": 0, - "max_humidity": 101, + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_humidity_min_max_1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "min_humidity": 0, + "max_humidity": 101, + } + } }, False, ), ( { - "name": "test_invalid_humidity_min_max_2", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "max_humidity": 20, - "min_humidity": 40, + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_humidity_min_max_2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "max_humidity": 20, + "min_humidity": 40, + } + } }, False, ), ( { - "name": "test_invalid_mode_is_reset", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", - "modes": ["eco", "None"], + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_mode_is_reset", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "None"], + } + } }, False, ), ], ) -async def test_validity_configurations(hass: HomeAssistant, config, valid) -> None: +async def test_validity_configurations( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + valid: bool, +) -> None: """Test validity of configurations.""" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {humidifier.DOMAIN: config}}, - ) - is valid - ) + if valid: + await mqtt_mock_entry_no_yaml_config() + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() @pytest.mark.parametrize( - ("name", "config", "success", "features"), + ("name", "hass_config", "success", "features"), [ ( "test1", { - "name": "test1", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, True, 0, @@ -932,21 +970,29 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ( "test2", { - "name": "test2", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", - "modes": ["eco", "auto"], + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "auto"], + } + } }, True, - humidifier.SUPPORT_MODES, + humidifier.HumidifierEntityFeature.MODES, ), ( "test3", { - "name": "test3", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test3", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, True, 0, @@ -954,20 +1000,28 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ( "test4", { - "name": "test4", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", - "modes": ["eco", "auto"], + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test4", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "auto"], + } + } }, True, - humidifier.SUPPORT_MODES, + humidifier.HumidifierEntityFeature.MODES, ), ( "test5", { - "name": "test5", - "command_topic": "command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test5", + "command_topic": "command-topic", + } + } }, False, None, @@ -975,8 +1029,12 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ( "test6", { - "name": "test6", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test6", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, False, None, @@ -985,27 +1043,20 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ) async def test_supported_features( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - name, - config, - success, - features, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + name: str, + success: bool, + features: humidifier.HumidifierEntityFeature | None, ) -> None: """Test supported features.""" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {humidifier.DOMAIN: config}}, - ) - is success - ) if success: - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(f"humidifier.{name}") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -1388,17 +1439,6 @@ async def test_setup_manual_entity_from_yaml( assert hass.states.get(f"{platform}.test") -async def test_config_schema_validation(hass: HomeAssistant) -> None: - """Test invalid platform options in the config schema do not pass the config validation.""" - platform = humidifier.DOMAIN - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][platform]) - config["name"] = "test" - CONFIG_SCHEMA({mqtt.DOMAIN: {platform: config}}) - CONFIG_SCHEMA({mqtt.DOMAIN: {platform: [config]}}) - with pytest.raises(MultipleInvalid): - CONFIG_SCHEMA({mqtt.DOMAIN: {platform: [{"bla": "bla"}]}}) - - async def test_unload_config_entry( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 42077fee0a..e4cf5bb803 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -32,9 +32,10 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -92,6 +93,28 @@ DEFAULT_CONFIG = { DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}} +DEFAULT_CONFIG_ALL_SERVICES = help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + ALL_SERVICES, SERVICE_TO_STRING + ) + }, + ), +) + + +def filter_options(default_config: ConfigType, options: set[str]) -> ConfigType: + """Generate a config from a default config with omitted options.""" + options_base: ConfigType = default_config[mqtt.DOMAIN][vacuum.DOMAIN] + config = deepcopy(default_config) + config[mqtt.DOMAIN][vacuum.DOMAIN] = { + key: value for key, value in options_base.items() if key not in options + } + return config + @pytest.fixture(autouse=True) def vacuum_platform_only(): @@ -100,13 +123,12 @@ def vacuum_platform_only(): yield +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_default_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the correct supported features.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( @@ -122,20 +144,15 @@ async def test_default_supported_features( ) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_all_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test simple commands to the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_turn_on(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_called_once_with( @@ -206,21 +223,27 @@ async def test_all_commands( } +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.STRING_TO_SERVICE["status"], SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_commands_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test commands which are not supported by the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = mqttvacuum.STRING_TO_SERVICE["status"] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_turn_on(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() @@ -259,21 +282,27 @@ async def test_commands_without_supported_features( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.STRING_TO_SERVICE["turn_on"], SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_attributes_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = mqttvacuum.STRING_TO_SERVICE["turn_on"] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54, @@ -291,20 +320,15 @@ async def test_attributes_without_supported_features( assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54, @@ -336,20 +360,15 @@ async def test_status( assert state.attributes.get(ATTR_FAN_SPEED) == "min" +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status_battery( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54 @@ -359,20 +378,16 @@ async def test_status_battery( assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status_cleaning( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "cleaning": true @@ -382,20 +397,15 @@ async def test_status_cleaning( assert state.state == STATE_ON +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status_docked( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "docked": true @@ -405,20 +415,15 @@ async def test_status_docked( assert state.state == STATE_OFF +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status_charging( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "charging": true @@ -428,20 +433,12 @@ async def test_status_charging( assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-outline" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_fan_speed( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "fan_speed": "max" @@ -451,62 +448,52 @@ async def test_status_fan_speed( assert state.attributes.get(ATTR_FAN_SPEED) == "max" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_fan_speed_list( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state.attributes.get(ATTR_FAN_SPEED_LIST) == ["min", "medium", "high", "max"] +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + ALL_SERVICES - VacuumEntityFeature.FAN_SPEED, SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_status_no_fan_speed_list( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum. If the vacuum doesn't support fan speed, fan speed list should be None. """ - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = ALL_SERVICES - VacuumEntityFeature.FAN_SPEED - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_error( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "error": "Error1" @@ -523,26 +510,26 @@ async def test_status_error( assert state.attributes.get(ATTR_STATUS) == "Stopped" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level", + mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}", + }, + ), + ) + ], +) async def test_battery_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that you can use non-default templates for battery_level.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.update( - { - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ), - mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level", - mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}", - } - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "retroroomba/battery_level", "54") state = hass.states.get("vacuum.mqtttest") @@ -550,20 +537,12 @@ async def test_battery_template( assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_invalid_json( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test to make sure nothing breaks if the vacuum sends bad JSON.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') state = hass.states.get("vacuum.mqtttest") @@ -571,63 +550,28 @@ async def test_status_invalid_json( assert state.attributes.get(ATTR_STATUS) == "Stopped" -async def test_missing_battery_template(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "hass_config", + [ + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE}), + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_CHARGING_TEMPLATE}), + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_CLEANING_TEMPLATE}), + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_DOCKED_TEMPLATE}), + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_ERROR_TEMPLATE}), + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_FAN_SPEED_TEMPLATE}), + ], +) +async def test_missing_templates( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_charging_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_CHARGING_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_cleaning_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_CLEANING_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_docked_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_DOCKED_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_error_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_ERROR_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_fan_speed_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_FAN_SPEED_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert ( + "Invalid config for [mqtt]: some but not all values in the same group of inclusion" + in caplog.text ) From db63c8584e22bd8b2afe3789d5204cc2dbd81da6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:16:54 +0100 Subject: [PATCH 0090/1165] Prepare MQTT platform tests part8 (#90132) * Tests state_vacuum * Tests siren --- tests/components/mqtt/test_siren.py | 279 +++++++++++---------- tests/components/mqtt/test_state_vacuum.py | 123 +++++---- 2 files changed, 214 insertions(+), 188 deletions(-) diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 9837a3cc8a..7b20e802a5 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -19,9 +19,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -86,13 +86,9 @@ async def async_turn_off( await hass.services.async_call(siren.DOMAIN, SERVICE_TURN_OFF, data, blocking=True) -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -103,10 +99,14 @@ async def test_controlling_state_via_topic( "payload_off": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -123,13 +123,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_OFF -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -140,10 +136,15 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": "2", } } - }, - ) + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_OFF @@ -167,15 +168,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_OFF -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -187,10 +182,16 @@ async def test_controlling_state_via_topic_and_json_message( "state_value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -210,15 +211,9 @@ async def test_controlling_state_via_topic_and_json_message( assert state.state == STATE_OFF -async def test_controlling_state_and_attributes_with_json_message_without_template( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message without a value template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -230,10 +225,16 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa "available_tones": ["ping", "siren", "bell"], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_and_attributes_with_json_message_without_template( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message without a value template.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -292,31 +293,41 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa assert state.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.6 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + { + mqtt.DOMAIN: { + siren.DOMAIN: { + "command_topic": "command-topic", + } + } + }, + ( + { + "name": "test1", + "available_tones": ["ping", "siren", "bell"], + "support_duration": False, + }, + { + "name": "test2", + "available_tones": ["ping", "siren", "bell"], + "support_volume_set": False, + }, + { + "name": "test3", + }, + ), + ) + ], +) async def test_filtering_not_supported_attributes_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting attributes with support flags optimistic.""" - config = { - "command_topic": "command-topic", - "available_tones": ["ping", "siren", "bell"], - } - config1 = copy.deepcopy(config) - config1["name"] = "test1" - config1["support_duration"] = False - config2 = copy.deepcopy(config) - config2["name"] = "test2" - config2["support_volume_set"] = False - config3 = copy.deepcopy(config) - config3["name"] = "test3" - del config3["available_tones"] - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {siren.DOMAIN: [config1, config2, config3]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state1 = hass.states.get("siren.test1") assert state1.state == STATE_OFF @@ -377,34 +388,44 @@ async def test_filtering_not_supported_attributes_optimistic( assert state3.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.88 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + { + mqtt.DOMAIN: { + siren.DOMAIN: { + "command_topic": "command-topic", + } + } + }, + ( + { + "name": "test1", + "state_topic": "state-topic1", + "available_tones": ["ping", "siren", "bell"], + "support_duration": False, + }, + { + "name": "test2", + "state_topic": "state-topic2", + "available_tones": ["ping", "siren", "bell"], + "support_volume_set": False, + }, + { + "name": "test3", + "state_topic": "state-topic3", + }, + ), + ) + ], +) async def test_filtering_not_supported_attributes_via_state( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting attributes with support flags via state.""" - config = { - "command_topic": "command-topic", - "available_tones": ["ping", "siren", "bell"], - } - config1 = copy.deepcopy(config) - config1["name"] = "test1" - config1["state_topic"] = "state-topic1" - config1["support_duration"] = False - config2 = copy.deepcopy(config) - config2["name"] = "test2" - config2["state_topic"] = "state-topic2" - config2["support_volume_set"] = False - config3 = copy.deepcopy(config) - config3["name"] = "test3" - config3["state_topic"] = "state-topic3" - del config3["available_tones"] - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {siren.DOMAIN: [config1, config2, config3]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state1 = hass.states.get("siren.test1") assert state1.state == STATE_UNKNOWN @@ -529,13 +550,9 @@ async def test_custom_availability_payload( ) -async def test_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the state payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -548,10 +565,14 @@ async def test_custom_state_payload( "state_off": "LOW", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_custom_state_payload( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the state payload.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -762,30 +783,36 @@ async def test_discovery_update_siren_template( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + DEFAULT_CONFIG, + ( + { + "name": "Beer", + "available_tones": ["ping", "chimes"], + "command_template": "CMD: {{ value }}, DURATION: {{ duration }}," + " TONE: {{ tone }}, VOLUME: {{ volume_level }}", + }, + { + "name": "Milk", + "available_tones": ["ping", "chimes"], + "command_template": "CMD: {{ value }}, DURATION: {{ duration }}," + " TONE: {{ tone }}, VOLUME: {{ volume_level }}", + "command_off_template": "CMD_OFF: {{ value }}", + }, + ), + ) + ], +) async def test_command_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test siren with command templates optimistic.""" - config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][siren.DOMAIN]) - config1["name"] = "Beer" - config1["available_tones"] = ["ping", "chimes"] - config1[ - "command_template" - ] = "CMD: {{ value }}, DURATION: {{ duration }}, TONE: {{ tone }}, VOLUME: {{ volume_level }}" - - config2 = copy.deepcopy(config1) - config2["name"] = "Milk" - config2["command_off_template"] = "CMD_OFF: {{ value }}" - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {siren.DOMAIN: [config1, config2]}}, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state1 = hass.states.get("siren.beer") assert state1.state == STATE_OFF diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index a5c8381310..dffaebca17 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -29,9 +29,9 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -83,6 +83,18 @@ DEFAULT_CONFIG = { DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"schema": "state", "name": "test"}}} +CONFIG_ALL_SERVICES = help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING + ) + }, + ), +) + @pytest.fixture(autouse=True) def vacuum_platform_only(): @@ -91,13 +103,12 @@ def vacuum_platform_only(): yield +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_default_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the correct supported features.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( @@ -105,20 +116,12 @@ async def test_default_supported_features( ) +@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_all_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test simple commands send to the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True @@ -181,21 +184,27 @@ async def test_all_commands( } +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.STRING_TO_SERVICE["status"], SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_commands_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test commands which are not supported by the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = mqttvacuum.STRING_TO_SERVICE["status"] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True @@ -243,20 +252,12 @@ async def test_commands_without_supported_features( mqtt_mock.async_publish.assert_not_called() +@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_status( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state.state == STATE_UNKNOWN @@ -292,21 +293,27 @@ async def test_status( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.DEFAULT_SERVICES, SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_no_fan_vacuum( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum when fan is not supported.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - del config[mqttvacuum.CONF_FAN_SPEED_LIST] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.DEFAULT_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54, @@ -347,21 +354,13 @@ async def test_no_fan_vacuum( assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 +@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) @pytest.mark.no_fail_on_log_exception async def test_status_invalid_json( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test to make sure nothing breaks if the vacuum sends bad JSON.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') state = hass.states.get("vacuum.mqtttest") From 185d6d74d75290cb331c2ebc98546c2ad17d9beb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:17:27 +0100 Subject: [PATCH 0091/1165] Prepare MQTT platform tests part9 (#90133) * Tests switch * Tests text * Tests update --- tests/components/mqtt/test_switch.py | 117 +++++++------ tests/components/mqtt/test_text.py | 137 +++++++-------- tests/components/mqtt/test_update.py | 238 +++++++++++++-------------- 3 files changed, 249 insertions(+), 243 deletions(-) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 83580edf00..79f2bcc4a7 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -15,7 +15,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -62,13 +61,9 @@ def switch_platform_only(): yield -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -80,10 +75,14 @@ async def test_controlling_state_via_topic( "device_class": "switch", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -106,16 +105,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_UNKNOWN -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands in optimistic mode.""" - fake_state = State("switch.test", "on") - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -126,10 +118,17 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": "2", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" + fake_state = State("switch.test", "on") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_ON @@ -153,13 +152,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_OFF -async def test_sending_inital_state_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the initial state in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -167,23 +162,23 @@ async def test_sending_inital_state_and_optimistic( "command_topic": "command-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_inital_state_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the initial state in optimistic mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -195,10 +190,14 @@ async def test_controlling_state_via_topic_and_json_message( "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic and JSON message.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -292,13 +291,9 @@ async def test_custom_availability_payload( ) -async def test_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the state payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -311,10 +306,14 @@ async def test_custom_state_payload( "state_off": "LOW", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_custom_state_payload( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the state payload.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index d12a03a9fa..10e9f0780d 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -14,7 +14,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -72,13 +71,9 @@ async def async_set_value( ) -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -88,10 +83,14 @@ async def test_controlling_state_via_topic( "mode": "password", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -114,15 +113,9 @@ async def test_controlling_state_via_topic( assert state.state == "" -async def test_controlling_validation_state_via_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the validation of a received state.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -135,10 +128,16 @@ async def test_controlling_validation_state_via_topic( "pattern": "(y|n)", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_validation_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the validation of a received state.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -188,11 +187,9 @@ async def test_controlling_validation_state_via_topic( assert state.state == "no" -async def test_attribute_validation_max_greater_then_min(hass: HomeAssistant) -> None: - """Test the validation of min and max configuration attributes.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -202,17 +199,20 @@ async def test_attribute_validation_max_greater_then_min(hass: HomeAssistant) -> "max": 10, } } - }, - ) - - -async def test_attribute_validation_max_not_greater_then_max_state_length( - hass: HomeAssistant, + } + ], +) +async def test_attribute_validation_max_greater_then_min( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: - """Test the max value of of max configuration attribute.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, + """Test the validation of min and max configuration attributes.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -222,17 +222,20 @@ async def test_attribute_validation_max_not_greater_then_max_state_length( "max": 257, } } - }, - ) - - -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_attribute_validation_max_not_greater_then_max_state_length( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: - """Test the sending MQTT commands in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test the max value of of max configuration attribute.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -241,10 +244,14 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": "2", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -269,13 +276,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == "some new state" -async def test_set_text_validation( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the initial state in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -287,10 +290,14 @@ async def test_set_text_validation( "pattern": "(y|n)", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_text_validation( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the initial state in optimistic mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 200a3ca6dd..bdd85768b8 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -14,7 +14,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -63,20 +62,14 @@ def update_platform_only(): yield -async def test_run_update_setup( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, - "latest_version_topic": latest_version_topic, + "state_topic": "test/installed-version", + "latest_version_topic": "test/latest-version", "name": "Test Update", "release_summary": "Test release summary", "release_url": "https://example.com/release", @@ -84,10 +77,16 @@ async def test_run_update_setup( "entity_picture": "https://example.com/icon.png", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_update_setup( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") async_fire_mqtt_message(hass, latest_version_topic, "1.9.0") @@ -113,20 +112,14 @@ async def test_run_update_setup( assert state.attributes.get("latest_version") == "2.0.0" -async def test_run_update_setup_float( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload when the version is parsable as a number.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, - "latest_version_topic": latest_version_topic, + "state_topic": "test/installed-version", + "latest_version_topic": "test/latest-version", "name": "Test Update", "release_summary": "Test release summary", "release_url": "https://example.com/release", @@ -134,10 +127,16 @@ async def test_run_update_setup_float( "entity_picture": "https://example.com/icon.png", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_update_setup_float( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload when the version is parsable as a number.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, "1.9") async_fire_mqtt_message(hass, latest_version_topic, "1.9") @@ -163,29 +162,29 @@ async def test_run_update_setup_float( assert state.attributes.get("latest_version") == "2.0" -async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, + "state_topic": "test/installed-version", "value_template": "{{ value_json.installed }}", - "latest_version_topic": latest_version_topic, + "latest_version_topic": "test/latest-version", "latest_version_template": "{{ value_json.latest }}", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, '{"installed":"1.9.0"}') async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"1.9.0"}') @@ -211,29 +210,29 @@ async def test_value_template( assert state.attributes.get("latest_version") == "2.0.0" -async def test_value_template_float( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template when the version is parsable as a number.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, + "state_topic": "test/installed-version", "value_template": "{{ value_json.installed }}", - "latest_version_topic": latest_version_topic, + "latest_version_topic": "test/latest-version", "latest_version_template": "{{ value_json.latest }}", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template_float( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template when the version is parsable as a number.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, '{"installed":"1.9"}') async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"1.9"}') @@ -259,25 +258,25 @@ async def test_value_template_float( assert state.attributes.get("latest_version") == "2.0" -async def test_empty_json_state_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test an empty JSON payload.""" - state_topic = "test/state-topic" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": state_topic, + "state_topic": "test/state-topic", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_empty_json_state_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test an empty JSON payload.""" + state_topic = "test/state-topic" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "{}") @@ -287,25 +286,25 @@ async def test_empty_json_state_message( assert state.state == STATE_UNKNOWN -async def test_json_state_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test whether it fetches data from a JSON payload.""" - state_topic = "test/state-topic" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": state_topic, + "state_topic": "test/state-topic", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_json_state_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test whether it fetches data from a JSON payload.""" + state_topic = "test/state-topic" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, @@ -343,26 +342,27 @@ async def test_json_state_message( assert state.attributes.get("entity_picture") == "https://example.com/icon2.png" -async def test_json_state_message_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test whether it fetches data from a JSON payload with template.""" - state_topic = "test/state-topic" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": state_topic, - "value_template": '{{ {"installed_version": value_json.installed, "latest_version": value_json.latest} | to_json }}', + "state_topic": "test/state-topic", + "value_template": '{{ {"installed_version": value_json.installed, ' + '"latest_version": value_json.latest} | to_json }}', "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_json_state_message_with_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test whether it fetches data from a JSON payload with template.""" + state_topic = "test/state-topic" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, '{"installed":"1.9.0","latest":"1.9.0"}') @@ -383,31 +383,31 @@ async def test_json_state_message_with_template( assert state.attributes.get("latest_version") == "2.0.0" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": "test/installed-version", + "latest_version_topic": "test/latest-version", + "command_topic": "test/install-command", + "payload_install": "install", + "name": "Test Update", + } + } + } + ], +) async def test_run_install_service( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that install service works.""" installed_version_topic = "test/installed-version" latest_version_topic = "test/latest-version" command_topic = "test/install-command" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - update.DOMAIN: { - "state_topic": installed_version_topic, - "latest_version_topic": latest_version_topic, - "command_topic": command_topic, - "payload_install": "install", - "name": "Test Update", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") async_fire_mqtt_message(hass, latest_version_topic, "2.0.0") From 3e3ece4e56d26b74445dfc235102c68e57ac15c3 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 23 Mar 2023 13:44:19 -0500 Subject: [PATCH 0092/1165] Add speech to text over binary websocket to pipeline (#90082) * Allow passing binary to the WS connection * Expand test coverage * Test non-existing handler * Add text to speech and stages to pipeline * Default to "cloud" TTS when engine is None * Refactor pipeline request to split text/audio * Refactor with PipelineRun * Generate pipeline from language * Clean up * Restore TTS code * Add audio pipeline test * Clean TTS cache in test * Clean up tests and pipeline base class * Stop pylint and pytest magics from fighting * Include mock_get_cache_files * Working on STT * Preparing to test * First successful test * Send handler_id * Allow signaling end of stream using empty payloads * Store handlers in a list * Handle binary handlers raising exceptions * Add stt/tts dependencies to voice_assistant * Include STT audio in pipeline test * Working on tests * Refactoring with stages * Fix tests * Add more tests * Add method docs * Change stt demo/cloud to AsyncIterable * Add pipeline error events * Move handler id to separate message before pipeline * Add test for invalid stage order * Change "finish" to "end" * Use enum --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/cloud/stt.py | 5 +- homeassistant/components/demo/stt.py | 6 +- homeassistant/components/stt/__init__.py | 5 +- .../components/voice_assistant/manifest.json | 2 +- .../components/voice_assistant/pipeline.py | 356 ++++++++++--- .../voice_assistant/websocket_api.py | 127 +++-- tests/components/stt/test_init.py | 4 +- .../voice_assistant/test_pipeline.py | 110 ---- .../voice_assistant/test_websocket.py | 479 +++++++++++++++++- 9 files changed, 860 insertions(+), 234 deletions(-) delete mode 100644 tests/components/voice_assistant/test_pipeline.py diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index 70618ab38e..bdce055c3c 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -1,7 +1,8 @@ """Support for the cloud for speech to text service.""" from __future__ import annotations -from aiohttp import StreamReader +from collections.abc import AsyncIterable + from hass_nabucasa import Cloud from hass_nabucasa.voice import VoiceError @@ -88,7 +89,7 @@ class CloudProvider(Provider): return [AudioChannels.CHANNEL_MONO] async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" content = ( diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 9c3cf89d80..923092fad2 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -1,7 +1,7 @@ """Support for the demo for speech to text service.""" from __future__ import annotations -from aiohttp import StreamReader +from collections.abc import AsyncIterable from homeassistant.components.stt import ( AudioBitRates, @@ -63,12 +63,12 @@ class DemoProvider(Provider): return [AudioChannels.CHANNEL_STEREO] async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" # Read available data - async for _ in stream.iter_chunked(4096): + async for _ in stream: pass return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 94e08d2536..6319940219 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -3,11 +3,12 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio +from collections.abc import AsyncIterable from dataclasses import asdict, dataclass import logging from typing import Any -from aiohttp import StreamReader, web +from aiohttp import web from aiohttp.hdrs import istr from aiohttp.web_exceptions import ( HTTPBadRequest, @@ -153,7 +154,7 @@ class Provider(ABC): @abstractmethod async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service. diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json index 6d353660b3..644c49e945 100644 --- a/homeassistant/components/voice_assistant/manifest.json +++ b/homeassistant/components/voice_assistant/manifest.json @@ -2,7 +2,7 @@ "domain": "voice_assistant", "name": "Voice Assistant", "codeowners": ["@balloob", "@synesthesiam"], - "dependencies": ["conversation"], + "dependencies": ["conversation", "stt", "tts"], "documentation": "https://www.home-assistant.io/integrations/voice_assistant", "iot_class": "local_push", "quality_scale": "internal" diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 0b55d72455..0070154bd4 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -1,33 +1,80 @@ """Classes for voice assistant pipelines.""" from __future__ import annotations -from abc import ABC, abstractmethod import asyncio -from collections.abc import Callable -from dataclasses import dataclass, field +from collections.abc import AsyncIterable, Callable +from dataclasses import asdict, dataclass, field +import logging from typing import Any from homeassistant.backports.enum import StrEnum -from homeassistant.components import conversation -from homeassistant.components.media_source import async_resolve_media +from homeassistant.components import conversation, media_source, stt from homeassistant.components.tts.media_source import ( generate_media_source_id as tts_generate_media_source_id, ) -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.util.dt import utcnow +from .const import DOMAIN + DEFAULT_TIMEOUT = 30 # seconds +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_get_pipeline( + hass: HomeAssistant, pipeline_id: str | None = None, language: str | None = None +) -> Pipeline | None: + """Get a pipeline by id or create one for a language.""" + if pipeline_id is not None: + return hass.data[DOMAIN].get(pipeline_id) + + # Construct a pipeline for the required/configured language + language = language or hass.config.language + return Pipeline( + name=language, + language=language, + stt_engine=None, # first engine + conversation_engine=None, # first agent + tts_engine=None, # first engine + ) + + +class PipelineError(Exception): + """Base class for pipeline errors.""" + + def __init__(self, code: str, message: str) -> None: + """Set error message.""" + self.code = code + self.message = message + + super().__init__(f"Pipeline error code={code}, message={message}") + + +class SpeechToTextError(PipelineError): + """Error in speech to text portion of pipeline.""" + + +class IntentRecognitionError(PipelineError): + """Error in intent recognition portion of pipeline.""" + + +class TextToSpeechError(PipelineError): + """Error in text to speech portion of pipeline.""" + class PipelineEventType(StrEnum): """Event types emitted during a pipeline run.""" RUN_START = "run-start" - RUN_FINISH = "run-finish" + RUN_END = "run-end" + STT_START = "stt-start" + STT_END = "stt-end" INTENT_START = "intent-start" - INTENT_FINISH = "intent-finish" + INTENT_END = "intent-end" TTS_START = "tts-start" - TTS_FINISH = "tts-finish" + TTS_END = "tts-end" ERROR = "error" @@ -54,10 +101,44 @@ class Pipeline: name: str language: str | None + stt_engine: str | None conversation_engine: str | None tts_engine: str | None +class PipelineStage(StrEnum): + """Stages of a pipeline.""" + + STT = "stt" + INTENT = "intent" + TTS = "tts" + + +PIPELINE_STAGE_ORDER = [ + PipelineStage.STT, + PipelineStage.INTENT, + PipelineStage.TTS, +] + + +class PipelineRunValidationError(Exception): + """Error when a pipeline run is not valid.""" + + +class InvalidPipelineStagesError(PipelineRunValidationError): + """Error when given an invalid combination of start/end stages.""" + + def __init__( + self, + start_stage: PipelineStage, + end_stage: PipelineStage, + ) -> None: + """Set error message.""" + super().__init__( + f"Invalid stage combination: start={start_stage}, end={end_stage}" + ) + + @dataclass class PipelineRun: """Running context for a pipeline.""" @@ -65,6 +146,8 @@ class PipelineRun: hass: HomeAssistant context: Context pipeline: Pipeline + start_stage: PipelineStage + end_stage: PipelineStage event_callback: Callable[[PipelineEvent], None] language: str = None # type: ignore[assignment] @@ -72,6 +155,12 @@ class PipelineRun: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language + # stt -> intent -> tts + if PIPELINE_STAGE_ORDER.index(self.end_stage) < PIPELINE_STAGE_ORDER.index( + self.start_stage + ): + raise InvalidPipelineStagesError(self.start_stage, self.end_stage) + def start(self): """Emit run start event.""" self.event_callback( @@ -84,18 +173,86 @@ class PipelineRun: ) ) - def finish(self): - """Emit run finish event.""" + def end(self): + """Emit run end event.""" self.event_callback( PipelineEvent( - PipelineEventType.RUN_FINISH, + PipelineEventType.RUN_END, ) ) + async def speech_to_text( + self, + metadata: stt.SpeechMetadata, + stream: AsyncIterable[bytes], + ) -> str: + """Run speech to text portion of pipeline. Returns the spoken text.""" + engine = self.pipeline.stt_engine or "default" + self.event_callback( + PipelineEvent( + PipelineEventType.STT_START, + { + "engine": engine, + "metadata": asdict(metadata), + }, + ) + ) + + try: + # Load provider + stt_provider = stt.async_get_provider(self.hass, self.pipeline.stt_engine) + assert stt_provider is not None + except Exception as src_error: + stt_error = SpeechToTextError( + code="stt-provider-missing", + message=f"No speech to text provider for: {engine}", + ) + _LOGGER.exception(stt_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": stt_error.code, "message": stt_error.message}, + ) + ) + raise stt_error from src_error + + try: + # Transcribe audio stream + result = await stt_provider.async_process_audio_stream(metadata, stream) + assert (result.text is not None) and ( + result.result == stt.SpeechResultState.SUCCESS + ) + except Exception as src_error: + stt_error = SpeechToTextError( + code="stt-stream-failed", + message="Unexpected error during speech to text", + ) + _LOGGER.exception(stt_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": stt_error.code, "message": stt_error.message}, + ) + ) + raise stt_error from src_error + + self.event_callback( + PipelineEvent( + PipelineEventType.STT_END, + { + "stt_output": { + "text": result.text, + } + }, + ) + ) + + return result.text + async def recognize_intent( self, intent_input: str, conversation_id: str | None - ) -> conversation.ConversationResult: - """Run intent recognition portion of pipeline.""" + ) -> str: + """Run intent recognition portion of pipeline. Returns text to speak.""" self.event_callback( PipelineEvent( PipelineEventType.INTENT_START, @@ -106,23 +263,39 @@ class PipelineRun: ) ) - conversation_result = await conversation.async_converse( - hass=self.hass, - text=intent_input, - conversation_id=conversation_id, - context=self.context, - language=self.language, - agent_id=self.pipeline.conversation_engine, - ) + try: + conversation_result = await conversation.async_converse( + hass=self.hass, + text=intent_input, + conversation_id=conversation_id, + context=self.context, + language=self.language, + agent_id=self.pipeline.conversation_engine, + ) + except Exception as src_error: + intent_error = IntentRecognitionError( + code="intent-failed", + message="Unexpected error during intent recognition", + ) + _LOGGER.exception(intent_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": intent_error.code, "message": intent_error.message}, + ) + ) + raise intent_error from src_error self.event_callback( PipelineEvent( - PipelineEventType.INTENT_FINISH, + PipelineEventType.INTENT_END, {"intent_output": conversation_result.as_dict()}, ) ) - return conversation_result + speech = conversation_result.response.speech.get("plain", {}).get("speech", "") + + return speech async def text_to_speech(self, tts_input: str) -> str: """Run text to speech portion of pipeline. Returns URL of TTS audio.""" @@ -136,29 +309,57 @@ class PipelineRun: ) ) - tts_media = await async_resolve_media( - self.hass, - tts_generate_media_source_id( + try: + # Synthesize audio and get URL + tts_media = await media_source.async_resolve_media( self.hass, - tts_input, - engine=self.pipeline.tts_engine, - ), - ) - tts_url = tts_media.url + tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.pipeline.tts_engine, + ), + ) + except Exception as src_error: + tts_error = TextToSpeechError( + code="tts-failed", + message="Unexpected error during text to speech", + ) + _LOGGER.exception(tts_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": tts_error.code, "message": tts_error.message}, + ) + ) + raise tts_error from src_error self.event_callback( PipelineEvent( - PipelineEventType.TTS_FINISH, - {"tts_output": tts_url}, + PipelineEventType.TTS_END, + {"tts_output": asdict(tts_media)}, ) ) - return tts_url + return tts_media.url @dataclass -class PipelineRequest(ABC): - """Request to for a pipeline run.""" +class PipelineInput: + """Input to a pipeline run.""" + + stt_metadata: stt.SpeechMetadata | None = None + """Metadata of stt input audio. Required when start_stage = stt.""" + + stt_stream: AsyncIterable[bytes] | None = None + """Input audio for stt. Required when start_stage = stt.""" + + intent_input: str | None = None + """Input for conversation agent. Required when start_stage = intent.""" + + tts_input: str | None = None + """Input for text to speech. Required when start_stage = tts.""" + + conversation_id: str | None = None async def execute( self, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT @@ -169,47 +370,60 @@ class PipelineRequest(ABC): timeout=timeout, ) - @abstractmethod async def _execute(self, run: PipelineRun): - """Run pipeline with request info and context.""" + self._validate(run.start_stage) - -@dataclass -class TextPipelineRequest(PipelineRequest): - """Request to run the text portion only of a pipeline.""" - - intent_input: str - conversation_id: str | None = None - - async def _execute( - self, - run: PipelineRun, - ): + # stt -> intent -> tts run.start() - await run.recognize_intent(self.intent_input, self.conversation_id) - run.finish() + current_stage = run.start_stage + # Speech to text + intent_input = self.intent_input + if current_stage == PipelineStage.STT: + assert self.stt_metadata is not None + assert self.stt_stream is not None + intent_input = await run.speech_to_text( + self.stt_metadata, + self.stt_stream, + ) + current_stage = PipelineStage.INTENT -@dataclass -class AudioPipelineRequest(PipelineRequest): - """Request to full pipeline from audio input (stt) to audio output (tts).""" + if run.end_stage != PipelineStage.STT: + tts_input = self.tts_input - intent_input: str # this will be changed to stt audio - conversation_id: str | None = None + if current_stage == PipelineStage.INTENT: + assert intent_input is not None + tts_input = await run.recognize_intent( + intent_input, self.conversation_id + ) + current_stage = PipelineStage.TTS - async def _execute(self, run: PipelineRun): - run.start() + if run.end_stage != PipelineStage.INTENT: + if current_stage == PipelineStage.TTS: + assert tts_input is not None + await run.text_to_speech(tts_input) - # stt will go here + run.end() - conversation_result = await run.recognize_intent( - self.intent_input, self.conversation_id - ) + def _validate(self, stage: PipelineStage): + """Validate pipeline input against start stage.""" + if stage == PipelineStage.STT: + if self.stt_metadata is None: + raise PipelineRunValidationError( + "stt_metadata is required for speech to text" + ) - tts_input = conversation_result.response.speech.get("plain", {}).get( - "speech", "" - ) - - await run.text_to_speech(tts_input) - - run.finish() + if self.stt_stream is None: + raise PipelineRunValidationError( + "stt_stream is required for speech to text" + ) + elif stage == PipelineStage.INTENT: + if self.intent_input is None: + raise PipelineRunValidationError( + "intent_input is required for intent recognition" + ) + elif stage == PipelineStage.TTS: + if self.tts_input is None: + raise PipelineRunValidationError( + "tts_input is required for text to speech" + ) diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index 54e87e292a..cc4799f13e 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -1,13 +1,24 @@ """Voice Assistant Websocket API.""" +import asyncio +from collections.abc import Callable +import logging from typing import Any import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import stt, websocket_api from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN -from .pipeline import DEFAULT_TIMEOUT, Pipeline, PipelineRun, TextPipelineRequest +from .pipeline import ( + DEFAULT_TIMEOUT, + PipelineError, + PipelineInput, + PipelineRun, + PipelineStage, + async_get_pipeline, +) + +_LOGGER = logging.getLogger(__name__) @callback @@ -19,9 +30,13 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", + # pylint: disable-next=unnecessary-lambda + vol.Required("start_stage"): lambda val: PipelineStage(val), + # pylint: disable-next=unnecessary-lambda + vol.Required("end_stage"): lambda val: PipelineStage(val), + vol.Optional("input"): {"text": str}, vol.Optional("language"): str, vol.Optional("pipeline"): str, - vol.Required("intent_input"): str, vol.Optional("conversation_id"): vol.Any(str, None), vol.Optional("timeout"): vol.Any(float, int), } @@ -33,39 +48,74 @@ async def websocket_run( msg: dict[str, Any], ) -> None: """Run a pipeline.""" + language = msg.get("language", hass.config.language) pipeline_id = msg.get("pipeline") - if pipeline_id is not None: - pipeline = hass.data[DOMAIN].get(pipeline_id) - if pipeline is None: - connection.send_error( - msg["id"], - "pipeline_not_found", - f"Pipeline not found: {pipeline_id}", - ) - return + pipeline = async_get_pipeline( + hass, + pipeline_id=pipeline_id, + language=language, + ) + if pipeline is None: + connection.send_error( + msg["id"], + "pipeline-not-found", + f"Pipeline not found: id={pipeline_id}, language={language}", + ) + return - else: - # Construct a pipeline for the required/configured language - language = msg.get("language", hass.config.language) - pipeline = Pipeline( - name=language, - language=language, - conversation_engine=None, - tts_engine=None, + timeout = msg.get("timeout", DEFAULT_TIMEOUT) + start_stage = PipelineStage(msg["start_stage"]) + end_stage = PipelineStage(msg["end_stage"]) + handler_id: int | None = None + unregister_handler: Callable[[], None] | None = None + + # Arguments to PipelineInput + input_args: dict[str, Any] = { + "conversation_id": msg.get("conversation_id"), + } + + if start_stage == PipelineStage.STT: + # Audio pipeline that will receive audio as binary websocket messages + audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue() + + async def stt_stream(): + # Yield until we receive an empty chunk + while chunk := await audio_queue.get(): + yield chunk + + def handle_binary(_hass, _connection, data: bytes): + # Forward to STT audio stream + audio_queue.put_nowait(data) + + handler_id, unregister_handler = connection.async_register_binary_handler( + handle_binary ) - # Run pipeline with a timeout. - # Events are sent over the websocket connection. - timeout = msg.get("timeout", DEFAULT_TIMEOUT) + # Audio input must be raw PCM at 16Khz with 16-bit mono samples + input_args["stt_metadata"] = stt.SpeechMetadata( + language=language, + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + input_args["stt_stream"] = stt_stream() + elif start_stage == PipelineStage.INTENT: + # Input to conversation agent + input_args["intent_input"] = msg["input"]["text"] + elif start_stage == PipelineStage.TTS: + # Input to text to speech system + input_args["tts_input"] = msg["input"]["text"] + run_task = hass.async_create_task( - TextPipelineRequest( - intent_input=msg["intent_input"], - conversation_id=msg.get("conversation_id"), - ).execute( + PipelineInput(**input_args).execute( PipelineRun( hass, - connection.context(msg), - pipeline, + context=connection.context(msg), + pipeline=pipeline, + start_stage=start_stage, + end_stage=end_stage, event_callback=lambda event: connection.send_event( msg["id"], event.as_dict() ), @@ -77,7 +127,20 @@ async def websocket_run( # Cancel pipeline if user unsubscribes connection.subscriptions[msg["id"]] = run_task.cancel + # Confirm subscription connection.send_result(msg["id"]) - # Task contains a timeout - await run_task + if handler_id is not None: + # Send handler id to client + connection.send_event(msg["id"], {"handler_id": handler_id}) + + try: + # Task contains a timeout + await run_task + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + finally: + if unregister_handler is not None: + # Unregister binary handler + unregister_handler() diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index e36b8af3f6..3d20dbc540 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -1,5 +1,5 @@ """Test STT component setup.""" -from asyncio import StreamReader +from collections.abc import AsyncIterable from http import HTTPStatus from unittest.mock import AsyncMock, Mock @@ -64,7 +64,7 @@ class MockProvider(Provider): return [AudioChannels.CHANNEL_MONO] async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream.""" self.calls.append((metadata, stream)) diff --git a/tests/components/voice_assistant/test_pipeline.py b/tests/components/voice_assistant/test_pipeline.py deleted file mode 100644 index 343719a49f..0000000000 --- a/tests/components/voice_assistant/test_pipeline.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Pipeline tests for Voice Assistant integration.""" -from unittest.mock import MagicMock, patch - -import pytest - -from homeassistant.components.voice_assistant.pipeline import ( - AudioPipelineRequest, - Pipeline, - PipelineEventType, - PipelineRun, -) -from homeassistant.core import Context -from homeassistant.setup import async_setup_component - -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - mock_get_cache_files, - mock_init_cache_dir, -) - - -@pytest.fixture(autouse=True) -async def init_components(hass): - """Initialize relevant components with empty configs.""" - assert await async_setup_component(hass, "voice_assistant", {}) - - -@pytest.fixture -async def mock_get_tts_audio(hass): - """Set up media source.""" - assert await async_setup_component(hass, "media_source", {}) - assert await async_setup_component( - hass, - "tts", - { - "tts": { - "platform": "demo", - } - }, - ) - - with patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=("mp3", b""), - ) as mock_get_tts: - yield mock_get_tts - - -async def test_audio_pipeline(hass, mock_get_tts_audio): - """Run audio pipeline with mock TTS.""" - pipeline = Pipeline( - name="test", - language=hass.config.language, - conversation_engine=None, - tts_engine=None, - ) - - event_callback = MagicMock() - await AudioPipelineRequest(intent_input="Are the lights on?").execute( - PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - event_callback=event_callback, - language=hass.config.language, - ) - ) - - calls = event_callback.mock_calls - assert calls[0].args[0].type == PipelineEventType.RUN_START - assert calls[0].args[0].data == { - "pipeline": "test", - "language": hass.config.language, - } - - assert calls[1].args[0].type == PipelineEventType.INTENT_START - assert calls[1].args[0].data == { - "engine": "default", - "intent_input": "Are the lights on?", - } - assert calls[2].args[0].type == PipelineEventType.INTENT_FINISH - assert calls[2].args[0].data == { - "intent_output": { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } - } - - assert calls[3].args[0].type == PipelineEventType.TTS_START - assert calls[3].args[0].data == { - "engine": "default", - "tts_input": "Sorry, I couldn't understand that", - } - assert calls[4].args[0].type == PipelineEventType.TTS_FINISH - assert ( - calls[4].args[0].data["tts_output"] - == f"/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_{hass.config.language}_-_demo.mp3" - ) - - assert calls[5].args[0].type == PipelineEventType.RUN_FINISH diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index 2fec6cdfb0..a1ba8b5f7c 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -1,20 +1,94 @@ """Websocket tests for Voice Assistant integration.""" import asyncio -from unittest.mock import patch +from collections.abc import AsyncIterable +from unittest.mock import MagicMock, patch import pytest +from homeassistant.components import stt from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import + mock_get_cache_files, + mock_init_cache_dir, +) from tests.typing import WebSocketGenerator +_TRANSCRIPT = "test transcript" + + +class MockSttProvider(stt.Provider): + """Mock STT provider.""" + + def __init__(self, hass: HomeAssistant, text: str) -> None: + """Init test provider.""" + self.hass = hass + self.text = text + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return [self.hass.config.language] + + @property + def supported_formats(self) -> list[stt.AudioFormats]: + """Return a list of supported formats.""" + return [stt.AudioFormats.WAV] + + @property + def supported_codecs(self) -> list[stt.AudioCodecs]: + """Return a list of supported codecs.""" + return [stt.AudioCodecs.PCM] + + @property + def supported_bit_rates(self) -> list[stt.AudioBitRates]: + """Return a list of supported bitrates.""" + return [stt.AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[stt.AudioSampleRates]: + """Return a list of supported samplerates.""" + return [stt.AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[stt.AudioChannels]: + """Return a list of supported channels.""" + return [stt.AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream.""" + return stt.SpeechResult(self.text, stt.SpeechResultState.SUCCESS) + @pytest.fixture(autouse=True) async def init_components(hass): """Initialize relevant components with empty configs.""" + assert await async_setup_component(hass, "media_source", {}) + assert await async_setup_component( + hass, + "tts", + { + "tts": { + "platform": "demo", + } + }, + ) + assert await async_setup_component(hass, "stt", {}) + + # mock_platform fails because it can't import + hass.data[stt.DOMAIN] = {"test": MockSttProvider(hass, _TRANSCRIPT)} + assert await async_setup_component(hass, "voice_assistant", {}) + with patch( + "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", + return_value=("mp3", b""), + ) as mock_get_tts: + yield mock_get_tts + async def test_text_only_pipeline( hass: HomeAssistant, @@ -27,7 +101,9 @@ async def test_text_only_pipeline( { "id": 5, "type": "voice_assistant/run", - "intent_input": "Are the lights on?", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, } ) @@ -52,7 +128,7 @@ async def test_text_only_pipeline( } msg = await client.receive_json() - assert msg["event"]["type"] == "intent-finish" + assert msg["event"]["type"] == "intent-end" assert msg["event"]["data"] == { "intent_output": { "response": { @@ -71,13 +147,120 @@ async def test_text_only_pipeline( } } - # run finish + # run end msg = await client.receive_json() - assert msg["event"]["type"] == "run-finish" + assert msg["event"]["type"] == "run-end" assert msg["event"]["data"] == {} -async def test_conversation_timeout( +async def test_audio_pipeline( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test events from a pipeline run with audio input/output.""" + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == { + "engine": "default", + "metadata": { + "bit_rate": 16, + "channel": 1, + "codec": "pcm", + "format": "wav", + "language": "en", + "sample_rate": 16000, + }, + } + + # End of audio stream (handler id + empty payload) + await client.send_bytes(b"1") + + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == { + "stt_output": {"text": _TRANSCRIPT}, + } + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == { + "engine": "default", + "intent_input": _TRANSCRIPT, + } + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == { + "intent_output": { + "response": { + "speech": { + "plain": { + "speech": "Sorry, I couldn't understand that", + "extra_data": None, + } + }, + "card": {}, + "language": "en", + "response_type": "error", + "data": {"code": "no_intent_match"}, + }, + "conversation_id": None, + } + } + + # text to speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == { + "engine": "default", + "tts_input": "Sorry, I couldn't understand that", + } + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == { + "tts_output": { + "url": f"/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_{hass.config.language}_-_demo.mp3", + "mime_type": "audio/mpeg", + }, + } + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == {} + + +async def test_intent_timeout( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components ) -> None: """Test partial pipeline run with conversation agent timeout.""" @@ -94,7 +277,9 @@ async def test_conversation_timeout( { "id": 5, "type": "voice_assistant/run", - "intent_input": "Are the lights on?", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, "timeout": 0.00001, } ) @@ -125,24 +310,26 @@ async def test_conversation_timeout( assert msg["error"]["code"] == "timeout" -async def test_pipeline_timeout( +async def test_text_pipeline_timeout( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components ) -> None: - """Test pipeline run with immediate timeout.""" + """Test text-only pipeline run with immediate timeout.""" client = await hass_ws_client(hass) async def sleepy_run(*args, **kwargs): await asyncio.sleep(3600) with patch( - "homeassistant.components.voice_assistant.pipeline.TextPipelineRequest._execute", + "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", new=sleepy_run, ): await client.send_json( { "id": 5, "type": "voice_assistant/run", - "intent_input": "Are the lights on?", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, "timeout": 0.0001, } ) @@ -155,3 +342,273 @@ async def test_pipeline_timeout( msg = await client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "timeout" + + +async def test_intent_failed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test text-only pipeline run with conversation agent error.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.conversation.async_converse", + new=MagicMock(return_value=RuntimeError), + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # intent start + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == { + "engine": "default", + "intent_input": "Are the lights on?", + } + + # intent error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "intent-failed" + + +async def test_audio_pipeline_timeout( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test audio pipeline run with immediate timeout.""" + client = await hass_ws_client(hass) + + async def sleepy_run(*args, **kwargs): + await asyncio.sleep(3600) + + with patch( + "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", + new=sleepy_run, + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + "timeout": 0.0001, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # timeout error + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "timeout" + + +async def test_stt_provider_missing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test events from a pipeline run with a non-existent STT provider.""" + with patch( + "homeassistant.components.stt.async_get_provider", + new=MagicMock(return_value=None), + ): + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == { + "engine": "default", + "metadata": { + "bit_rate": 16, + "channel": 1, + "codec": "pcm", + "format": "wav", + "language": "en", + "sample_rate": 16000, + }, + } + + # End of audio stream (handler id + empty payload) + await client.send_bytes(b"1") + + # stt error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "stt-provider-missing" + + +async def test_stt_stream_failed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test events from a pipeline run with a non-existent STT provider.""" + with patch( + "tests.components.voice_assistant.test_websocket.MockSttProvider.async_process_audio_stream", + new=MagicMock(side_effect=RuntimeError), + ): + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == { + "engine": "default", + "metadata": { + "bit_rate": 16, + "channel": 1, + "codec": "pcm", + "format": "wav", + "language": "en", + "sample_rate": 16000, + }, + } + + # End of audio stream (handler id + empty payload) + await client.send_bytes(b"1") + + # stt error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "stt-stream-failed" + + +async def test_tts_failed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test pipeline run with text to speech error.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.media_source.async_resolve_media", + new=MagicMock(return_value=RuntimeError), + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # tts start + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == { + "engine": "default", + "tts_input": "Lights are on.", + } + + # tts error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "tts-failed" + + +async def test_invalid_stage_order( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test pipeline run with invalid stage order.""" + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "tts", + "end_stage": "stt", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] From 38a4f08e157c5992510b8f972ac7ee2fdfc9ddde Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Mar 2023 20:10:51 +0100 Subject: [PATCH 0093/1165] Fix missing mock in islamic_prayer_times (#90178) * Fix missing mock in islamic_prayer_times * Restore 100% coverage * Update test_config_flow.py --- .../islamic_prayer_times/conftest.py | 15 +++++++++++ .../islamic_prayer_times/test_config_flow.py | 25 ++++++------------- .../islamic_prayer_times/test_init.py | 21 ++++++++++++++++ 3 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 tests/components/islamic_prayer_times/conftest.py diff --git a/tests/components/islamic_prayer_times/conftest.py b/tests/components/islamic_prayer_times/conftest.py new file mode 100644 index 0000000000..63c6ad8414 --- /dev/null +++ b/tests/components/islamic_prayer_times/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the islamic_prayer_times tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.islamic_prayer_times.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index 6643093873..a25b8ba0f0 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -1,15 +1,15 @@ """Tests for Islamic Prayer Times config flow.""" -from unittest.mock import patch +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD, DOMAIN from homeassistant.core import HomeAssistant -from . import PRAYER_TIMES - from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_flow_works(hass: HomeAssistant) -> None: """Test user config.""" @@ -19,13 +19,11 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.islamic_prayer_times.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == "Islamic Prayer Times" @@ -40,13 +38,6 @@ async def test_options(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) - with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", - return_value=PRAYER_TIMES, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index d641a22590..b1cf8f2c9a 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -8,6 +8,7 @@ import pytest from homeassistant import config_entries from homeassistant.components import islamic_prayer_times +from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD from homeassistant.core import HomeAssistant from . import ( @@ -85,6 +86,26 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert islamic_prayer_times.DOMAIN not in hass.data +async def test_options_listener(hass: HomeAssistant) -> None: + """Ensure updating options triggers a coordinator refresh.""" + entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) + entry.add_to_hass(hass) + + with patch( + "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ) as mock_fetch_prayer_times: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert mock_fetch_prayer_times.call_count == 1 + + hass.config_entries.async_update_entry( + entry, options={CONF_CALC_METHOD: "makkah"} + ) + await hass.async_block_till_done() + assert mock_fetch_prayer_times.call_count == 2 + + async def test_islamic_prayer_times_timestamp_format(hass: HomeAssistant) -> None: """Test Islamic prayer times timestamp format.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) From dd0f05b98069cc331b57614bcdab4e7a4b174003 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 09:55:02 -1000 Subject: [PATCH 0094/1165] Avoid calling the http access logging when logging is disabled (#90152) --- homeassistant/components/http/__init__.py | 24 ++++++++- tests/components/http/test_init.py | 60 ++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 04b94dc3b8..3106eea05f 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -12,6 +12,7 @@ from typing import Any, Final, TypedDict, cast from aiohttp import web from aiohttp.typedefs import StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection +from aiohttp.web_log import AccessLogger from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -220,6 +221,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +class HomeAssistantAccessLogger(AccessLogger): + """Access logger for Home Assistant that does not log when disabled.""" + + def log( + self, request: web.BaseRequest, response: web.StreamResponse, time: float + ) -> None: + """Log the request. + + The default implementation logs the request to the logger + with the INFO level and than throws it away if the logger + is not enabled for the INFO level. This implementation + does not log the request if the logger is not enabled for + the INFO level. + """ + if not self.logger.isEnabledFor(logging.INFO): + return + super().log(request, response, time) + + class HomeAssistantHTTP: """HTTP server for Home Assistant.""" @@ -462,7 +482,9 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner(self.app) + self.runner = web.AppRunner( + self.app, access_log_class=HomeAssistantAccessLogger + ) await self.runner.setup() self.site = HomeAssistantTCPSite( diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 578fcc60c7..0c346ab947 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,10 +1,12 @@ """The tests for the Home Assistant HTTP component.""" +import asyncio from datetime import timedelta from http import HTTPStatus from ipaddress import ip_network import logging import pathlib -from unittest.mock import Mock, patch +import time +from unittest.mock import MagicMock, Mock, patch import py import pytest @@ -20,6 +22,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern from tests.common import async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMockResponse from tests.typing import ClientSessionGenerator @@ -463,3 +466,58 @@ async def test_storing_config( restored["trusted_proxies"][0] = ip_network(restored["trusted_proxies"][0]) assert restored == http.HTTP_SCHEMA(config) + + +async def test_logging( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Testing the access log works.""" + await asyncio.gather( + *( + async_setup_component(hass, component, {}) + for component in ("http", "logger", "api") + ) + ) + hass.states.async_set("logging.entity", "hello") + await hass.services.async_call( + "logger", + "set_level", + {"aiohttp.access": "info"}, + blocking=True, + ) + client = await hass_client() + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK + + assert "GET /api/states/logging.entity" in caplog.text + caplog.clear() + await hass.services.async_call( + "logger", + "set_level", + {"aiohttp.access": "warning"}, + blocking=True, + ) + 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_hass_access_logger_at_info_level( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that logging happens at info level.""" + test_logger = logging.getLogger("test.aiohttp.logger") + logger = http.HomeAssistantAccessLogger(test_logger) + mock_request = MagicMock() + response = AiohttpClientMockResponse( + "POST", "http://127.0.0.1", status=HTTPStatus.OK + ) + setattr(response, "body_length", 42) + logger.log(mock_request, response, time.time()) + assert "42" in caplog.text + caplog.clear() + test_logger.setLevel(logging.WARNING) + logger.log(mock_request, response, time.time()) + assert "42" not in caplog.text From d49fbc17dfbfad9821779bd2fa9277b67263c1e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:52:37 -1000 Subject: [PATCH 0095/1165] Fix recorder attribute excludes not being effective until after startup (#90198) * Fix attribute excludes not being effective until after startup fixes #90016 * reduce --- homeassistant/components/recorder/__init__.py | 41 ++++++++++++++++--- homeassistant/components/recorder/const.py | 13 ++++++ .../components/recorder/statistics.py | 26 ++++++++---- homeassistant/components/recorder/tasks.py | 5 +-- tests/components/recorder/test_init.py | 8 +++- 5 files changed, 75 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 2621db9cb7..750f504d09 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,6 +28,9 @@ from .const import ( # noqa: F401 EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, EXCLUDE_ATTRIBUTES, + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, + INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD, SQLITE_URL_PREFIX, ) from .core import Recorder @@ -165,14 +168,40 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_services(hass, instance) websocket_api.async_setup(hass) entity_registry.async_setup(hass) - await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) + + await _async_setup_integration_platform( + hass, instance, exclude_attributes_by_domain + ) return await instance.async_db_ready -async def _process_recorder_platform( - hass: HomeAssistant, domain: str, platform: Any +async def _async_setup_integration_platform( + hass: HomeAssistant, + instance: Recorder, + exclude_attributes_by_domain: dict[str, set[str]], ) -> None: - """Process a recorder platform.""" - instance = get_instance(hass) - instance.queue_task(AddRecorderPlatformTask(domain, platform)) + """Set up a recorder integration platform.""" + + async def _process_recorder_platform( + hass: HomeAssistant, domain: str, platform: Any + ) -> None: + """Process a recorder platform.""" + # We need to add this before as soon as the component is loaded + # to ensure by the time the state is recorded that the excluded + # attributes are known. This is safe to modify in the event loop + # since exclude_attributes_by_domain is never iterated over. + if exclude_attributes := getattr( + platform, INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, None + ): + exclude_attributes_by_domain[domain] = exclude_attributes(hass) + + # If the platform has a compile_statistics method, we need to + # add it to the recorder queue to be processed. + if any( + hasattr(platform, _attr) + for _attr in INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD + ): + instance.queue_task(AddRecorderPlatformTask(domain, platform)) + + await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 6bf46efd36..fbec19a2d1 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -51,6 +51,19 @@ STATES_META_SCHEMA_VERSION = 38 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 +INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES = "exclude_attributes" + +INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics" +INTEGRATION_PLATFORM_VALIDATE_STATISTICS = "validate_statistics" +INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" + +INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD = { + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_VALIDATE_STATISTICS, + INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, +} + + class SupportedDialect(StrEnum): """Supported dialects.""" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 82fbf7798f..8025616d24 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -47,6 +47,9 @@ from .const import ( DOMAIN, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, + INTEGRATION_PLATFORM_VALIDATE_STATISTICS, SupportedDialect, ) from .db_schema import ( @@ -502,9 +505,13 @@ def _compile_statistics( current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} # Collect statistics from all platforms implementing support for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): - if not hasattr(platform, "compile_statistics"): + if not ( + platform_compile_statistics := getattr( + platform, INTEGRATION_PLATFORM_COMPILE_STATISTICS, None + ) + ): continue - compiled: PlatformCompiledStatistics = platform.compile_statistics( + compiled: PlatformCompiledStatistics = platform_compile_statistics( instance.hass, start, end ) _LOGGER.debug( @@ -783,9 +790,13 @@ def list_statistic_ids( # # Query all integrations with a registered recorder platform for platform in hass.data[DOMAIN].recorder_platforms.values(): - if not hasattr(platform, "list_statistic_ids"): + if not ( + platform_list_statistic_ids := getattr( + platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None + ) + ): continue - platform_statistic_ids = platform.list_statistic_ids( + platform_statistic_ids = platform_list_statistic_ids( hass, statistic_ids=statistic_ids, statistic_type=statistic_type ) @@ -1931,9 +1942,10 @@ def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]] """Validate statistics.""" platform_validation: dict[str, list[ValidationIssue]] = {} for platform in hass.data[DOMAIN].recorder_platforms.values(): - if not hasattr(platform, "validate_statistics"): - continue - platform_validation.update(platform.validate_statistics(hass)) + if platform_validate_statistics := getattr( + platform, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, None + ): + platform_validation.update(platform_validate_statistics(hass)) return platform_validation diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 7b8fa4867b..ef11885705 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -14,7 +14,7 @@ from homeassistant.core import Event from homeassistant.helpers.typing import UndefinedType from . import entity_registry, purge, statistics -from .const import DOMAIN, EXCLUDE_ATTRIBUTES +from .const import DOMAIN from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData from .util import periodic_db_cleanups @@ -317,11 +317,8 @@ class AddRecorderPlatformTask(RecorderTask): hass = instance.hass domain = self.domain platform = self.platform - platforms: dict[str, Any] = hass.data[DOMAIN].recorder_platforms platforms[domain] = platform - if hasattr(self.platform, "exclude_attributes"): - hass.data[EXCLUDE_ATTRIBUTES][domain] = platform.exclude_attributes(hass) @dataclass diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 3232b10fdc..8fb45cb3d4 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -2112,10 +2112,15 @@ async def test_connect_args_priority(hass: HomeAssistant, config_url) -> None: assert connect_params[0]["charset"] == "utf8mb4" +@pytest.mark.parametrize("core_state", [CoreState.starting, CoreState.running]) async def test_excluding_attributes_by_integration( - recorder_mock: Recorder, hass: HomeAssistant, entity_registry: er.EntityRegistry + recorder_mock: Recorder, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + core_state: CoreState, ) -> None: """Test that an integration's recorder platform can exclude attributes.""" + hass.state = core_state state = "restoring_from_db" attributes = {"test_attr": 5, "excluded": 10} mock_platform( @@ -2131,6 +2136,7 @@ async def test_excluding_attributes_by_integration( platform = MockEntityPlatform(hass, platform_name="fake_integration") entity_platform = MockEntity(entity_id=entity_id, extra_state_attributes=attributes) await platform.async_add_entities([entity_platform]) + await hass.async_block_till_done() await async_wait_recording_done(hass) From e7e7f603c24dda93c114968c36ae9fc890971bb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:53:18 -1000 Subject: [PATCH 0096/1165] Remove async_response from websocket apis where nothing was being awaited (#90204) --- homeassistant/components/logger/websocket_api.py | 4 ++-- homeassistant/components/websocket_api/commands.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 1b4e5cb36a..89026a07b8 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -27,9 +27,9 @@ def async_load_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, handle_module_log_level) +@callback @websocket_api.websocket_command({vol.Required("type"): "logger/log_info"}) -@websocket_api.async_response -async def handle_integration_log_info( +def handle_integration_log_info( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations logger info.""" diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index fa5c6aac29..2b146d9447 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -399,9 +399,9 @@ async def handle_manifest_get( connection.send_error(msg["id"], const.ERR_NOT_FOUND, "Integration not found") +@callback @decorators.websocket_command({vol.Required("type"): "integration/setup_info"}) -@decorators.async_response -async def handle_integration_setup_info( +def handle_integration_setup_info( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations command.""" @@ -648,6 +648,7 @@ async def handle_execute_script( connection.send_result(msg["id"], {"context": context}) +@callback @decorators.websocket_command( { vol.Required("type"): "fire_event", @@ -656,8 +657,7 @@ async def handle_execute_script( } ) @decorators.require_admin -@decorators.async_response -async def handle_fire_event( +def handle_fire_event( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle fire event command.""" From a44d6f30c9744241807f63d6c0659786d8f270da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:56:04 -1000 Subject: [PATCH 0097/1165] Fix refactoring error in states/events context id migration (#90193) fixes #90074 --- homeassistant/components/recorder/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index bbdab2690d..8e522a2bbd 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -718,7 +718,7 @@ class Recorder(threading.Thread): if ( self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION or execute_stmt_lambda_element( - session, has_events_context_ids_to_migrate() + session, has_states_context_ids_to_migrate() ) ): self.queue_task(StatesContextIDMigrationTask()) @@ -726,7 +726,7 @@ class Recorder(threading.Thread): if ( self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION or execute_stmt_lambda_element( - session, has_states_context_ids_to_migrate() + session, has_events_context_ids_to_migrate() ) ): self.queue_task(EventsContextIDMigrationTask()) From f1ec77b8e07eb9c8fd140a32327f6085040ad355 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:56:58 -1000 Subject: [PATCH 0098/1165] Small cleanups to logbook statement generator (#90200) We should only convert the context id to binary if its going to be used. Avoid some intermediate vars that are no longer needed --- .../components/logbook/queries/__init__.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index cfef16bf77..0172700df4 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -30,10 +30,10 @@ def statement_for_request( """Generate the logbook statement for a logbook request.""" start_day = dt_util.utc_to_timestamp(start_day_dt) end_day = dt_util.utc_to_timestamp(end_day_dt) - context_id_bin = ulid_to_bytes_or_none(context_id) # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: + context_id_bin = ulid_to_bytes_or_none(context_id) states_entity_filter = ( filters.states_metadata_entity_filter() if filters else None ) @@ -54,34 +54,30 @@ def statement_for_request( # entities and devices: logbook sends everything for the timeframe for the entities and devices if entity_ids and device_ids: - json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] - json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return entities_devices_stmt( start_day, end_day, event_types, states_metadata_ids or [], - json_quoted_entity_ids, - json_quoted_device_ids, + [json_dumps(entity_id) for entity_id in entity_ids], + [json_dumps(device_id) for device_id in device_ids], ) # entities: logbook sends everything for the timeframe for the entities if entity_ids: - json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] return entities_stmt( start_day, end_day, event_types, states_metadata_ids or [], - json_quoted_entity_ids, + [json_dumps(entity_id) for entity_id in entity_ids], ) # devices: logbook sends everything for the timeframe for the devices assert device_ids is not None - json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return devices_stmt( start_day, end_day, event_types, - json_quoted_device_ids, + [json_dumps(device_id) for device_id in device_ids], ) From ca157f4d19edfa30c52a32d02439a26efdf576a7 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Fri, 24 Mar 2023 02:23:05 -0400 Subject: [PATCH 0099/1165] Add icons for qbittorrent speed sensors (#90203) add icons for qbittorrent speed sensors --- homeassistant/components/qbittorrent/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index e7b75954d5..26605a8765 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -44,6 +44,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, name="Down Speed", + icon="mdi:cloud-download", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, @@ -51,6 +52,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, name="Up Speed", + icon="mdi:cloud-upload", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, From 1f2268a878f095bda28cf8ee09da6c68007c90a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 21:40:47 -1000 Subject: [PATCH 0100/1165] Fix httpx client creating a new ssl context with each client (memory leak) (#90191) * Fix httpx client creating a new ssl context with each client While working on https://github.com/home-assistant/core/issues/83524 it was discovered that each new httpx client creates a new ssl context https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_transports/default.py#L261 If an ssl context is passed in creating a new one is avoided here https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_config.py#L110 This change makes httpx ssl no-verify behavior match aiohttp ssl no-verify behavior https://github.com/aio-libs/aiohttp/blob/6da04694fd87a39af9c3856048c9ff23ca815f88/aiohttp/connector.py#L892 aiohttp solved this by wrapping the code that generates the ssl context in an lru_cache * compact --- homeassistant/helpers/aiohttp_client.py | 2 +- homeassistant/helpers/httpx_client.py | 7 +++++-- homeassistant/util/ssl.py | 27 +++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 53c3cc1cf2..78a8051df1 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -273,7 +273,7 @@ def _async_get_connector( if verify_ssl: ssl_context: bool | SSLContext = ssl_util.get_default_context() else: - ssl_context = False + ssl_context = ssl_util.get_default_no_verify_context() connector = aiohttp.TCPConnector( enable_cleanup_closed=True, diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index 1e9d2e776c..44ad81c73e 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -11,7 +11,7 @@ from typing_extensions import Self from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass -from homeassistant.util import ssl as ssl_util +from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .frame import warn_use @@ -65,8 +65,11 @@ def create_async_httpx_client( This method must be run in the event loop. """ + ssl_context = ( + get_default_context() if verify_ssl else get_default_no_verify_context() + ) client = HassHttpXAsyncClient( - verify=ssl_util.get_default_context() if verify_ssl else False, + verify=ssl_context, headers={USER_AGENT: SERVER_SOFTWARE}, **kwargs, ) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 9c945ef275..5b8830e657 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -1,10 +1,31 @@ """Helper to create SSL contexts.""" +import contextlib from os import environ import ssl import certifi +def create_no_verify_ssl_context() -> ssl.SSLContext: + """Return an SSL context that does not verify the server certificate. + + This is a copy of aiohttp's create_default_context() function, with the + ssl verify turned off. + + https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 + """ + sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + sslcontext.options |= ssl.OP_NO_SSLv2 + sslcontext.options |= ssl.OP_NO_SSLv3 + sslcontext.check_hostname = False + sslcontext.verify_mode = ssl.CERT_NONE + with contextlib.suppress(AttributeError): + # This only works for OpenSSL >= 1.0.0 + sslcontext.options |= ssl.OP_NO_COMPRESSION + sslcontext.set_default_verify_paths() + return sslcontext + + def client_context() -> ssl.SSLContext: """Return an SSL context for making requests.""" @@ -18,6 +39,7 @@ def client_context() -> ssl.SSLContext: # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = client_context() +_DEFAULT_NO_VERIFY_SSL_CONTEXT = create_no_verify_ssl_context() def get_default_context() -> ssl.SSLContext: @@ -25,6 +47,11 @@ def get_default_context() -> ssl.SSLContext: return _DEFAULT_SSL_CONTEXT +def get_default_no_verify_context() -> ssl.SSLContext: + """Return the default SSL context that does not verify the server certificate.""" + return _DEFAULT_NO_VERIFY_SSL_CONTEXT + + def server_context_modern() -> ssl.SSLContext: """Return an SSL context following the Mozilla recommendations. From a404d5f6d74783463db0cb5c944e467ba9d19d37 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:41:04 +0100 Subject: [PATCH 0101/1165] Prepare MQTT platform tests part5 (#90108) * Tests light_json * Tests light_template * Tests light --- tests/components/mqtt/test_light.py | 1217 ++++++++++-------- tests/components/mqtt/test_light_json.py | 635 ++++----- tests/components/mqtt/test_light_template.py | 323 ++--- 3 files changed, 1175 insertions(+), 1000 deletions(-) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 7d0c0333b7..6ad52103a0 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -196,7 +196,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -243,26 +242,26 @@ def light_platform_only(): yield +@pytest.mark.parametrize( + "hass_config", [{mqtt.DOMAIN: {light.DOMAIN: {"name": "test"}}}] +) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {light.DOMAIN: {"name": "test"}}} - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']. Got None." in caplog.text ) -async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test if there is no color and brightness if no topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -271,10 +270,14 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( "command_topic": "test_light_rgb/set", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test if there is no color and brightness if no topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -315,41 +318,46 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_state_topic": "test_light_rgb/effect/status", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_state_topic": "test_light_rgb/xy/status", + "xy_command_topic": "test_light_rgb/xy/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the controlling of the state via topic.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_state_topic": "test_light_rgb/effect/status", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_state_topic": "test_light_rgb/xy/status", - "xy_command_topic": "test_light_rgb/xy/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -436,43 +444,47 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "color_mode_state_topic": "test_light_rgb/color_mode/status", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_state_topic": "test_light_rgb/effect/status", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_state_topic": "test_light_rgb/xy/status", + "xy_command_topic": "test_light_rgb/xy/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_invalid_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of empty data via topic.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "color_mode_state_topic": "test_light_rgb/color_mode/status", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_state_topic": "test_light_rgb/effect/status", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_state_topic": "test_light_rgb/xy/status", - "xy_command_topic": "test_light_rgb/xy/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -564,13 +576,9 @@ async def test_invalid_state_via_topic( assert light_state.attributes["color_temp"] == 153 -async def test_brightness_controlling_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the brightness controlling scale.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -585,10 +593,14 @@ async def test_brightness_controlling_scale( "payload_off": "off", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_brightness_controlling_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the brightness controlling scale.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -614,13 +626,9 @@ async def test_brightness_controlling_scale( assert light_state.attributes["brightness"] == 255 -async def test_brightness_from_rgb_controlling_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the brightness controlling scale.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -634,9 +642,14 @@ async def test_brightness_from_rgb_controlling_scale( "payload_off": "off", } } - }, - ) - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_brightness_from_rgb_controlling_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the brightness controlling scale.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() state = hass.states.get("light.test") @@ -675,47 +688,52 @@ async def test_brightness_from_rgb_controlling_scale( assert state.attributes.get("rgb_color") == (255, 127, 63) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_command_topic": "test_light_rgb/rgbw/set", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_command_topic": "test_light_rgb/xy/set", + "brightness_state_topic": "test_light_rgb/brightness/status", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "effect_state_topic": "test_light_rgb/effect/status", + "hs_state_topic": "test_light_rgb/hs/status", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "xy_state_topic": "test_light_rgb/xy/status", + "state_value_template": "{{ value_json.hello }}", + "brightness_value_template": "{{ value_json.hello }}", + "color_temp_value_template": "{{ value_json.hello }}", + "effect_value_template": "{{ value_json.hello }}", + "hs_value_template": '{{ value_json.hello | join(",") }}', + "rgb_value_template": '{{ value_json.hello | join(",") }}', + "rgbw_value_template": '{{ value_json.hello | join(",") }}', + "rgbww_value_template": '{{ value_json.hello | join(",") }}', + "xy_value_template": '{{ value_json.hello | join(",") }}', + } + } + } + ], +) async def test_controlling_state_via_topic_with_templates( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of the state with a template.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_command_topic": "test_light_rgb/rgbw/set", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_command_topic": "test_light_rgb/xy/set", - "brightness_state_topic": "test_light_rgb/brightness/status", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "effect_state_topic": "test_light_rgb/effect/status", - "hs_state_topic": "test_light_rgb/hs/status", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "xy_state_topic": "test_light_rgb/xy/status", - "state_value_template": "{{ value_json.hello }}", - "brightness_value_template": "{{ value_json.hello }}", - "color_temp_value_template": "{{ value_json.hello }}", - "effect_value_template": "{{ value_json.hello }}", - "hs_value_template": '{{ value_json.hello | join(",") }}', - "rgb_value_template": '{{ value_json.hello | join(",") }}', - "rgbw_value_template": '{{ value_json.hello | join(",") }}', - "rgbww_value_template": '{{ value_json.hello | join(",") }}', - "xy_value_template": '{{ value_json.hello | join(",") }}', - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -777,28 +795,35 @@ async def test_controlling_state_via_topic_with_templates( assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_command_topic": "test_light_rgb/xy/set", + "effect_list": ["colorloop", "random"], + "qos": 2, + "payload_on": "on", + "payload_off": "off", + } + } + } + ], +) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_command_topic": "test_light_rgb/xy/set", - "effect_list": ["colorloop", "random"], - "qos": 2, - "payload_on": "on", - "payload_off": "off", - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] fake_state = State( "light.test", @@ -813,9 +838,7 @@ async def test_sending_mqtt_commands_and_optimistic( ) mock_restore_cache(hass, (fake_state,)) - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -966,26 +989,30 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgb_command_template": '{{ "#%02x%02x%02x" | ' + "format(red, green, blue)}}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_rgb_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of RGB command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgb_command_template": '{{ "#%02x%02x%02x" | ' - "format(red, green, blue)}}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1005,26 +1032,30 @@ async def test_sending_mqtt_rgb_command_with_template( assert state.attributes["rgb_color"] == (255, 128, 64) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbw_command_template": '{{ "#%02x%02x%02x%02x" | ' + "format(red, green, blue, white)}}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_rgbw_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of RGBW command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbw_command_template": '{{ "#%02x%02x%02x%02x" | ' - "format(red, green, blue, white)}}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1044,26 +1075,30 @@ async def test_sending_mqtt_rgbw_command_with_template( assert state.attributes["rgbw_color"] == (255, 128, 64, 32) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "rgbww_command_template": '{{ "#%02x%02x%02x%02x%02x" | ' + "format(red, green, blue, cold_white, warm_white)}}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_rgbww_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of RGBWW command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "rgbww_command_template": '{{ "#%02x%02x%02x%02x%02x" | ' - "format(red, green, blue, cold_white, warm_white)}}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1083,25 +1118,29 @@ async def test_sending_mqtt_rgbww_command_with_template( assert state.attributes["rgbww_color"] == (255, 128, 64, 32, 16) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_color_temp/set", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_command_template": "{{ (1000 / value) | round(0) }}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_color_temp_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of Color Temp command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_color_temp/set", - "color_temp_command_topic": "test_light_color_temp/color_temp/set", - "color_temp_command_template": "{{ (1000 / value) | round(0) }}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1121,22 +1160,26 @@ async def test_sending_mqtt_color_temp_command_with_template( assert state.attributes["color_temp"] == 100 +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + "on_command_type": "first", + } + } + } + ], +) async def test_on_command_first( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command being sent before brightness.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - "on_command_type": "first", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1159,21 +1202,25 @@ async def test_on_command_first( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + } + } + } + ], +) async def test_on_command_last( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command being sent after brightness.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1196,23 +1243,27 @@ async def test_on_command_last( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + "rgb_command_topic": "test_light/rgb", + "on_command_type": "brightness", + } + } + } + ], +) async def test_on_command_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command being sent as only brightness.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - "rgb_command_topic": "test_light/rgb", - "on_command_type": "brightness", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1253,24 +1304,28 @@ async def test_on_command_brightness( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + "brightness_scale": 100, + "rgb_command_topic": "test_light/rgb", + "on_command_type": "brightness", + } + } + } + ], +) async def test_on_command_brightness_scaled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test brightness scale.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - "brightness_scale": 100, - "rgb_command_topic": "test_light/rgb", - "on_command_type": "brightness", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1325,21 +1380,25 @@ async def test_on_command_brightness_scaled( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgb_command_topic": "test_light/rgb", + } + } + } + ], +) async def test_on_command_rgb( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGB brightness mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgb_command_topic": "test_light/rgb", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1417,21 +1476,25 @@ async def test_on_command_rgb( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbw_command_topic": "test_light/rgbw", + } + } + } + ], +) async def test_on_command_rgbw( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBW brightness mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbw_command_topic": "test_light/rgbw", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1509,21 +1572,25 @@ async def test_on_command_rgbw( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbww_command_topic": "test_light/rgbww", + } + } + } + ], +) async def test_on_command_rgbww( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBWW brightness mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbww_command_topic": "test_light/rgbww", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1601,22 +1668,26 @@ async def test_on_command_rgbww( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgb_command_topic": "test_light/rgb", + "rgb_command_template": "{{ red }}/{{ green }}/{{ blue }}", + } + } + } + ], +) async def test_on_command_rgb_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGB brightness mode with RGB template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgb_command_topic": "test_light/rgb", - "rgb_command_template": "{{ red }}/{{ green }}/{{ blue }}", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1640,22 +1711,26 @@ async def test_on_command_rgb_template( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbw_command_topic": "test_light/rgbw", + "rgbw_command_template": "{{ red }}/{{ green }}/{{ blue }}/{{ white }}", + } + } + } + ], +) async def test_on_command_rgbw_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBW brightness mode with RGBW template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbw_command_topic": "test_light/rgbw", - "rgbw_command_template": "{{ red }}/{{ green }}/{{ blue }}/{{ white }}", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1678,22 +1753,27 @@ async def test_on_command_rgbw_template( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbww_command_topic": "test_light/rgbww", + "rgbww_command_template": "{{ red }}/{{ green }}/{{ blue }}" + "/{{ cold_white }}/{{ warm_white }}", + } + } + } + ], +) async def test_on_command_rgbww_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBWW brightness mode with RGBWW template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbww_command_topic": "test_light/rgbww", - "rgbww_command_template": "{{ red }}/{{ green }}/{{ blue }}/{{ cold_white }}/{{ warm_white }}", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1717,34 +1797,39 @@ async def test_on_command_rgbww_template( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "tasmota_B94927/cmnd/POWER", + "state_value_template": "{{ value_json.POWER }}", + "payload_off": "OFF", + "payload_on": "ON", + "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", + "brightness_scale": 100, + "on_command_type": "brightness", + "brightness_value_template": "{{ value_json.Dimmer }}", + "rgb_command_topic": "tasmota_B94927/cmnd/Color2", + "rgb_value_template": "{{value_json.Color.split(',')[0:3]|join(',')}}", + "white_command_topic": "tasmota_B94927/cmnd/White", + "white_scale": 100, + "color_mode_value_template": "{% if value_json.White %} white {% else %} rgb {% endif %}", + "qos": "0", + } + } + } + ], +) async def test_on_command_white( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test sending commands for RGB + white light.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "tasmota_B94927/cmnd/POWER", - "state_value_template": "{{ value_json.POWER }}", - "payload_off": "OFF", - "payload_on": "ON", - "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", - "brightness_scale": 100, - "on_command_type": "brightness", - "brightness_value_template": "{{ value_json.Dimmer }}", - "rgb_command_topic": "tasmota_B94927/cmnd/Color2", - "rgb_value_template": "{{value_json.Color.split(',')[0:3]|join(',')}}", - "white_command_topic": "tasmota_B94927/cmnd/White", - "white_scale": 100, - "color_mode_value_template": "{% if value_json.White %} white {% else %} rgb {% endif %}", - "qos": "0", - } - } color_modes = ["rgb", "white"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1796,42 +1881,47 @@ async def test_on_command_white( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "color_mode_state_topic": "test_light_rgb/color_mode/status", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_state_topic": "test_light_rgb/effect/status", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_state_topic": "test_light_rgb/xy/status", + "xy_command_topic": "test_light_rgb/xy/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_explicit_color_mode( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test explicit color mode over mqtt.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "color_mode_state_topic": "test_light_rgb/color_mode/status", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_state_topic": "test_light_rgb/effect/status", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_state_topic": "test_light_rgb/xy/status", - "xy_command_topic": "test_light_rgb/xy/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1946,33 +2036,38 @@ async def test_explicit_color_mode( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "color_mode_state_topic": "test_light_rgb/color_mode/status", + "color_mode_value_template": "{{ value_json.color_mode }}", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_explicit_color_mode_templated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test templated explicit color mode over mqtt.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "color_mode_state_topic": "test_light_rgb/color_mode/status", - "color_mode_value_template": "{{ value_json.color_mode }}", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } color_modes = ["color_temp", "hs"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2029,38 +2124,45 @@ async def test_explicit_color_mode_templated( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "tasmota_B94927/tele/STATE", + "command_topic": "tasmota_B94927/cmnd/POWER", + "state_value_template": "{{ value_json.POWER }}", + "payload_off": "OFF", + "payload_on": "ON", + "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", + "brightness_state_topic": "tasmota_B94927/tele/STATE", + "brightness_scale": 100, + "on_command_type": "brightness", + "brightness_value_template": "{{ value_json.Dimmer }}", + "rgb_command_topic": "tasmota_B94927/cmnd/Color2", + "rgb_state_topic": "tasmota_B94927/tele/STATE", + "rgb_value_template": "{{value_json.Color.split(',')" + "[0:3]|join(',')}}", + "white_command_topic": "tasmota_B94927/cmnd/White", + "white_scale": 100, + "color_mode_state_topic": "tasmota_B94927/tele/STATE", + "color_mode_value_template": "{% if value_json.White %} white " + "{% else %} rgb {% endif %}", + "qos": "0", + } + } + } + ], +) async def test_white_state_update( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test state updates for RGB + white light.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "tasmota_B94927/tele/STATE", - "command_topic": "tasmota_B94927/cmnd/POWER", - "state_value_template": "{{ value_json.POWER }}", - "payload_off": "OFF", - "payload_on": "ON", - "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", - "brightness_state_topic": "tasmota_B94927/tele/STATE", - "brightness_scale": 100, - "on_command_type": "brightness", - "brightness_value_template": "{{ value_json.Dimmer }}", - "rgb_command_topic": "tasmota_B94927/cmnd/Color2", - "rgb_state_topic": "tasmota_B94927/tele/STATE", - "rgb_value_template": "{{value_json.Color.split(',')[0:3]|join(',')}}", - "white_command_topic": "tasmota_B94927/cmnd/White", - "white_scale": 100, - "color_mode_state_topic": "tasmota_B94927/tele/STATE", - "color_mode_value_template": "{% if value_json.White %} white {% else %} rgb {% endif %}", - "qos": "0", - } - } color_modes = ["rgb", "white"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2095,22 +2197,26 @@ async def test_white_state_update( assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "effect_command_topic": "test_light/effect/set", + "effect_list": ["rainbow", "colorloop"], + } + } + } + ], +) async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test effect.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "effect_command_topic": "test_light/effect/set", - "effect_list": ["rainbow", "colorloop"], - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2893,22 +2999,26 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_max_mireds/set", + "color_temp_command_topic": "test_max_mireds/color_temp/set", + "max_mireds": 370, + } + } + } + ], +) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_max_mireds/set", - "color_temp_command_topic": "test_max_mireds/color_temp/set", - "max_mireds": 370, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 @@ -3145,25 +3255,29 @@ async def test_encoding_subscribable_topics_brightness( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_brightness/set", + "brightness_command_topic": "test_light_brightness/brightness/set", + "brightness_command_template": "{{ (1000 / value) | round(0) }}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_brightness_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of Brightness command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_brightness/set", - "brightness_command_topic": "test_light_brightness/brightness/set", - "brightness_command_template": "{{ (1000 / value) | round(0) }}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3183,27 +3297,31 @@ async def test_sending_mqtt_brightness_command_with_template( assert state.attributes["brightness"] == 100 +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_brightness/set", + "brightness_command_topic": "test_light_brightness/brightness/set", + "effect_command_topic": "test_light_brightness/effect/set", + "effect_command_template": '{ "effect": "{{ value }}" }', + "effect_list": ["colorloop", "random"], + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_effect_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of Effect command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_brightness/set", - "brightness_command_topic": "test_light_brightness/brightness/set", - "effect_command_topic": "test_light_brightness/effect/set", - "effect_command_template": '{ "effect": "{{ value }}" }', - "effect_list": ["colorloop", "random"], - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3227,23 +3345,27 @@ async def test_sending_mqtt_effect_command_with_template( assert state.attributes.get("effect") == "colorloop" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_hs/set", + "hs_command_topic": "test_light_hs/hs_color/set", + "hs_command_template": '{"hue": {{ hue | int }}, "sat": {{ sat | int}}}', + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_hs_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of HS Color command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_hs/set", - "hs_command_topic": "test_light_hs/hs_color/set", - "hs_command_template": '{"hue": {{ hue | int }}, "sat": {{ sat | int}}}', - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3263,23 +3385,30 @@ async def test_sending_mqtt_hs_command_with_template( assert state.attributes["hs_color"] == (30, 100) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_xy/set", + "xy_command_topic": "test_light_xy/xy_color/set", + "xy_command_template": "{" + '"Color": "{{ (x * 65536) | round | int }},' + '{{ (y * 65536) | round | int }}"' + "}", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_xy_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of XY Color command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_xy/set", - "xy_command_topic": "test_light_xy/xy_color/set", - "xy_command_template": '{"Color": "{{ (x * 65536) | round | int }},{{ (y * 65536) | round | int }}"}', - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index db39cea784..1b24f7636f 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -98,10 +98,10 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from homeassistant.util.json import JsonValueType, json_loads from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -144,6 +144,30 @@ DEFAULT_CONFIG = { } +COLOR_MODES_CONFIG = { + mqtt.DOMAIN: { + light.DOMAIN: { + "brightness": True, + "color_mode": True, + "effect": True, + "command_topic": "test_light_rgb/set", + "name": "test", + "schema": "json", + "supported_color_modes": [ + "color_temp", + "hs", + "rgb", + "rgbw", + "rgbww", + "white", + "xy", + ], + "qos": 0, + } + } +} + + @pytest.fixture(autouse=True) def light_platform_only(): """Only setup the light platform to speed up tests.""" @@ -163,44 +187,40 @@ class JsonValidator: return json_loads(self.jsondata) == json_loads(other) +@pytest.mark.parametrize( + "hass_config", [{mqtt.DOMAIN: {light.DOMAIN: {"schema": "json", "name": "test"}}}] +) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if setup fails with no command topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {light.DOMAIN: {"schema": "json", "name": "test"}}}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']. Got None." in caplog.text ) -@pytest.mark.parametrize("deprecated", ("color_temp", "hs", "rgb", "xy")) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"color_temp": True},)), + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"hs": True},)), + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"rgb": True},)), + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"xy": True},)), + ], +) async def test_fail_setup_if_color_mode_deprecated( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, deprecated + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if setup fails if color mode is combined with deprecated config keys.""" - supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - - config = { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "name": "test", - "schema": "json", - "supported_color_modes": supported_color_modes, - } - } - config[light.DOMAIN][deprecated] = True - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: config}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: color_mode must not be combined with any of" in caplog.text @@ -208,42 +228,49 @@ async def test_fail_setup_if_color_mode_deprecated( @pytest.mark.parametrize( - ("supported_color_modes", "error"), + ("hass_config", "error"), [ - (["onoff", "rgb"], "Unknown error calling mqtt CONFIG_SCHEMA"), - (["brightness", "rgb"], "Unknown error calling mqtt CONFIG_SCHEMA"), - (["unknown"], "Invalid config for [mqtt]: value must be one of [ None: """Test if setup fails if supported color modes is invalid.""" - config = { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "name": "test", - "schema": "json", - "supported_color_modes": supported_color_modes, - } - } - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: config}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert error in caplog.text -async def test_legacy_rgb_light( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test legacy RGB light flags expected features and color modes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -253,10 +280,14 @@ async def test_legacy_rgb_light( "rgb": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_legacy_rgb_light( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test legacy RGB light flags expected features and color modes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") color_modes = [light.ColorMode.HS] @@ -265,13 +296,9 @@ async def test_legacy_rgb_light( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features -async def test_no_color_brightness_color_temp_if_no_topics( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for no RGB, brightness, color temp, effector XY.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -281,10 +308,14 @@ async def test_no_color_brightness_color_temp_if_no_topics( "command_topic": "test_light_rgb/set", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_color_brightness_color_temp_if_no_topics( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for no RGB, brightness, color temp, effector XY.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -319,13 +350,9 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling of the state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -342,10 +369,14 @@ async def test_controlling_state_via_topic( "qos": "0", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling of the state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -463,35 +494,22 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get("effect") == "colorloop" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, COLOR_MODES_CONFIG, ({"state_topic": "test_light_rgb"},) + ) + ], +) async def test_controlling_state_via_topic2( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling of the state via topic for a light supporting color mode.""" supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "white", "xy"] - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "effect": True, - "name": "test", - "qos": "0", - "schema": "json", - "state_topic": "test_light_rgb", - "supported_color_modes": supported_color_modes, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -641,25 +659,9 @@ async def test_controlling_state_via_topic2( ) -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of command in optimistic mode.""" - fake_state = State( - "light.test", - "on", - { - "brightness": 95, - "hs_color": [100, 100], - "effect": "random", - "color_temp": 100, - }, - ) - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -675,10 +677,26 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": 2, } } + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of command in optimistic mode.""" + fake_state = State( + "light.test", + "on", + { + "brightness": 95, + "hs_color": [100, 100], + "effect": "random", + "color_temp": 100, }, ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -788,8 +806,35 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes["xy_color"] == (0.611, 0.375) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "brightness": True, + "color_mode": True, + "command_topic": "test_light_rgb/set", + "effect": True, + "name": "test", + "qos": 2, + "schema": "json", + "supported_color_modes": [ + "color_temp", + "hs", + "rgb", + "rgbw", + "rgbww", + "white", + "xy", + ], + } + } + } + ], +) async def test_sending_mqtt_commands_and_optimistic2( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode for a light supporting color mode.""" supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "white", "xy"] @@ -806,26 +851,7 @@ async def test_sending_mqtt_commands_and_optimistic2( ) mock_restore_cache(hass, (fake_state,)) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "effect": True, - "name": "test", - "qos": 2, - "schema": "json", - "supported_color_modes": supported_color_modes, - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -1020,13 +1046,9 @@ async def test_sending_mqtt_commands_and_optimistic2( mqtt_mock.async_publish.reset_mock() -async def test_sending_hs_color( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends hs color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1037,10 +1059,14 @@ async def test_sending_hs_color( "hs": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_hs_color( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends hs color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1083,13 +1109,9 @@ async def test_sending_hs_color( ) -async def test_sending_rgb_color_no_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1099,10 +1121,15 @@ async def test_sending_rgb_color_no_brightness( "rgb": True, } } - }, - ) + } + ], +) +async def test_sending_rgb_color_no_brightness( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1140,14 +1167,9 @@ async def test_sending_rgb_color_no_brightness( ) -async def test_sending_rgb_color_no_brightness2( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - supported_color_modes = ["rgb", "rgbw", "rgbww"] - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1155,13 +1177,17 @@ async def test_sending_rgb_color_no_brightness2( "command_topic": "test_light_rgb/set", "name": "test", "schema": "json", - "supported_color_modes": supported_color_modes, + "supported_color_modes": ["rgb", "rgbw", "rgbww"], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_rgb_color_no_brightness2( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1221,13 +1247,9 @@ async def test_sending_rgb_color_no_brightness2( ) -async def test_sending_rgb_color_with_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1238,10 +1260,14 @@ async def test_sending_rgb_color_with_brightness( "rgb": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_rgb_color_with_brightness( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1289,13 +1315,9 @@ async def test_sending_rgb_color_with_brightness( ) -async def test_sending_rgb_color_with_scaled_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1307,10 +1329,15 @@ async def test_sending_rgb_color_with_scaled_brightness( "rgb": True, } } - }, - ) + } + ], +) +async def test_sending_rgb_color_with_scaled_brightness( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1358,13 +1385,9 @@ async def test_sending_rgb_color_with_scaled_brightness( ) -async def test_sending_scaled_white( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with scaled white.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1378,10 +1401,14 @@ async def test_sending_scaled_white( "white_scale": 50, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_scaled_white( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with scaled white.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1405,13 +1432,9 @@ async def test_sending_scaled_white( mqtt_mock.async_publish.reset_mock() -async def test_sending_xy_color( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends xy color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1422,10 +1445,14 @@ async def test_sending_xy_color( "xy": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_xy_color( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends xy color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1467,13 +1494,9 @@ async def test_sending_xy_color( ) -async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for effect being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1484,10 +1507,14 @@ async def test_effect( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_effect( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for effect being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1533,13 +1560,9 @@ async def test_effect( assert state.attributes.get("effect") == "colorloop" -async def test_flash_short_and_long( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for flash length being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1551,10 +1574,14 @@ async def test_flash_short_and_long( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_flash_short_and_long( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for flash length being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1598,13 +1625,9 @@ async def test_flash_short_and_long( assert state.state == STATE_OFF -async def test_transition( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for transition time being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1614,10 +1637,14 @@ async def test_transition( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_transition( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for transition time being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1648,13 +1675,9 @@ async def test_transition( assert state.state == STATE_OFF -async def test_brightness_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for brightness scaling.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1666,10 +1689,14 @@ async def test_brightness_scale( "brightness_scale": 99, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_brightness_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for brightness scaling.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1693,13 +1720,9 @@ async def test_brightness_scale( assert state.attributes.get("brightness") == 255 -async def test_white_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for white scaling.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1714,10 +1737,14 @@ async def test_white_scale( "white_scale": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_white_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for white scaling.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1754,13 +1781,9 @@ async def test_white_scale( assert state.attributes.get("brightness") == 128 -async def test_invalid_values( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that invalid color/brightness/etc. values are ignored.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1774,10 +1797,14 @@ async def test_invalid_values( "qos": "0", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_invalid_values( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that invalid color/brightness/etc. values are ignored.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2203,23 +2230,27 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "color_temp": True, + "max_mireds": 370, + } + } + } + ], +) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - config = { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_max_mireds/set", - "color_temp": True, - "max_mireds": 370, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index c8018d4847..166687b859 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -44,7 +44,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -100,53 +99,60 @@ def light_platform_only(): @pytest.mark.parametrize( - "test_config", + "hass_config", [ - ({"schema": "template", "name": "test"},), + ({mqtt.DOMAIN: {light.DOMAIN: {"schema": "template", "name": "test"}}},), ( { - "schema": "template", - "name": "test", - "command_topic": "test_topic", + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_topic", + } + } }, ), ( { - "schema": "template", - "name": "test", - "command_topic": "test_topic", - "command_on_template": "on", + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_topic", + "command_on_template": "on", + } + } }, ), ( { - "schema": "template", - "name": "test", - "command_topic": "test_topic", - "command_off_template": "off", + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_topic", + "command_off_template": "off", + } + } }, ), ], ) async def test_setup_fails( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, test_config + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that setup fails with missing required configuration items.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {light.DOMAIN: test_config}}, - ) - assert "Invalid config for [mqtt]" in caplog.text + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert "Invalid config" in caplog.text -async def test_rgb_light( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test RGB light flags brightness support.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -160,10 +166,14 @@ async def test_rgb_light( "blue_template": '{{ value.split(",")[4].' 'split("-")[2] }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_rgb_light( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test RGB light flags brightness support.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -173,13 +183,9 @@ async def test_rgb_light( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features -async def test_state_change_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state change via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -197,10 +203,14 @@ async def test_state_change_via_topic( "state_template": '{{ value.split(",")[0] }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_change_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state change via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -228,13 +238,9 @@ async def test_state_change_via_topic( assert state.state == STATE_UNKNOWN -async def test_state_brightness_color_effect_temp_change_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state, bri, color, effect, color temp change.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -254,16 +260,20 @@ async def test_state_brightness_color_effect_temp_change_via_topic( "state_template": '{{ value.split(",")[0] }}', "brightness_template": '{{ value.split(",")[1] }}', "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].' 'split("-")[0] }}', - "green_template": '{{ value.split(",")[3].' 'split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].' 'split("-")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', "effect_template": '{{ value.split(",")[4] }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_brightness_color_effect_temp_change_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state, bri, color, effect, color temp change.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -339,25 +349,9 @@ async def test_state_brightness_color_effect_temp_change_via_topic( assert light_state.attributes.get("effect") == "rainbow" -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of command in optimistic mode.""" - fake_state = State( - "light.test", - "on", - { - "brightness": 95, - "hs_color": [100, 100], - "effect": "random", - "color_temp": 100, - }, - ) - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -384,10 +378,26 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": 2, } } + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of command in optimistic mode.""" + fake_state = State( + "light.test", + "on", + { + "brightness": 95, + "hs_color": [100, 100], + "effect": "random", + "color_temp": 100, }, ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -481,13 +491,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get("rgb_color") == (0, 255, 127) -async def test_sending_mqtt_commands_non_optimistic_brightness_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of command in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -508,16 +514,20 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( "state_template": '{{ value.split(",")[0] }}', "brightness_template": '{{ value.split(",")[1] }}', "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].' 'split("-")[0] }}', - "green_template": '{{ value.split(",")[3].' 'split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].' 'split("-")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', "effect_template": '{{ value.split(",")[4] }}', } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_non_optimistic_brightness_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of command in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -604,13 +614,9 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( state = hass.states.get("light.test") -async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test effect sent over MQTT in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -623,10 +629,14 @@ async def test_effect( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_effect( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test effect sent over MQTT in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -659,13 +669,9 @@ async def test_effect( assert state.attributes.get("effect") == "colorloop" -async def test_flash( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test flash sent over MQTT in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -677,10 +683,14 @@ async def test_flash( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_flash( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test flash sent over MQTT in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -710,13 +720,9 @@ async def test_flash( assert state.state == STATE_ON -async def test_transition( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for transition time being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -728,10 +734,14 @@ async def test_transition( "qos": 1, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_transition( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for transition time being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -754,13 +764,9 @@ async def test_transition( assert state.state == STATE_OFF -async def test_invalid_values( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that invalid values are ignored.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -780,16 +786,21 @@ async def test_invalid_values( "state_template": '{{ value.split(",")[0] }}', "brightness_template": '{{ value.split(",")[1] }}', "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].' 'split("-")[0] }}', - "green_template": '{{ value.split(",")[3].' 'split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].' 'split("-")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', "effect_template": '{{ value.split(",")[4] }}', } } - }, - ) + } + ], +) +async def test_invalid_values( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that invalid values are ignored.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1173,25 +1184,29 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_max_mireds/set", + "command_on_template": "on", + "command_off_template": "off", + "color_temp_template": "{{ value }}", + "max_mireds": 370, + } + } + } + ], +) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - config = { - light.DOMAIN: { - "schema": "template", - "name": "test", - "command_topic": "test_max_mireds/set", - "command_on_template": "on", - "command_off_template": "off", - "color_temp_template": "{{ value }}", - "max_mireds": 370, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 From 0570405a3cd81deb59fdfeb296bb5b8c99a5894e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:41:36 +0100 Subject: [PATCH 0102/1165] Prepare MQTT platform tests part6 (#90129) * Tests lock * Tests mixins * Tests number * Tests scene --- tests/components/mqtt/test_lock.py | 407 +++++++++++----------- tests/components/mqtt/test_mixins.py | 37 +- tests/components/mqtt/test_number.py | 490 +++++++++++++++------------ tests/components/mqtt/test_scene.py | 29 +- 4 files changed, 506 insertions(+), 457 deletions(-) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 0c8b6680c5..f13de40353 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -25,9 +25,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -63,6 +63,22 @@ DEFAULT_CONFIG = { mqtt.DOMAIN: {lock.DOMAIN: {"name": "test", "command_topic": "test-topic"}} } +CONFIG_WITH_STATES = { + mqtt.DOMAIN: { + lock.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "state_locked": "closed", + "state_locking": "closing", + "state_unlocked": "open", + "state_unlocking": "opening", + } + } +} + @pytest.fixture(autouse=True) def lock_platform_only(): @@ -72,42 +88,22 @@ def lock_platform_only(): @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ("LOCKED", STATE_LOCKED), - ("LOCKING", STATE_LOCKING), - ("UNLOCKED", STATE_UNLOCKED), - ("UNLOCKING", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "closed", STATE_LOCKED), + (CONFIG_WITH_STATES, "closing", STATE_LOCKING), + (CONFIG_WITH_STATES, "open", STATE_UNLOCKED), + (CONFIG_WITH_STATES, "opening", STATE_UNLOCKING), ], ) async def test_controlling_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "LOCKED", - "state_locking": "LOCKING", - "state_unlocked": "UNLOCKED", - "state_unlocking": "UNLOCKING", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -115,48 +111,29 @@ async def test_controlling_state_via_topic( assert not state.attributes.get(ATTR_SUPPORTED_FEATURES) async_fire_mqtt_message(hass, "state-topic", payload) + await hass.async_block_till_done() state = hass.states.get("lock.test") assert state.state is lock_state @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ("closed", STATE_LOCKED), - ("closing", STATE_LOCKING), - ("open", STATE_UNLOCKED), - ("opening", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "closed", STATE_LOCKED), + (CONFIG_WITH_STATES, "closing", STATE_LOCKING), + (CONFIG_WITH_STATES, "open", STATE_UNLOCKED), + (CONFIG_WITH_STATES, "opening", STATE_UNLOCKING), ], ) async def test_controlling_non_default_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "closed", - "state_locking": "closing", - "state_unlocked": "open", - "state_unlocking": "opening", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -169,43 +146,54 @@ async def test_controlling_non_default_state_via_topic( @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ('{"val":"LOCKED"}', STATE_LOCKED), - ('{"val":"LOCKING"}', STATE_LOCKING), - ('{"val":"UNLOCKED"}', STATE_UNLOCKED), - ('{"val":"UNLOCKING"}', STATE_UNLOCKING), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closed"}', + STATE_LOCKED, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closing"}', + STATE_LOCKING, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"opening"}', + STATE_UNLOCKING, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"open"}', + STATE_UNLOCKED, + ), ], ) async def test_controlling_state_via_topic_and_json_message( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "LOCKED", - "state_locking": "LOCKING", - "state_unlocked": "UNLOCKED", - "state_unlocking": "UNLOCKING", - "value_template": "{{ value_json.val }}", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -217,43 +205,54 @@ async def test_controlling_state_via_topic_and_json_message( @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ('{"val":"closed"}', STATE_LOCKED), - ('{"val":"closing"}', STATE_LOCKING), - ('{"val":"open"}', STATE_UNLOCKED), - ('{"val":"opening"}', STATE_UNLOCKING), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closed"}', + STATE_LOCKED, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closing"}', + STATE_LOCKING, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"open"}', + STATE_UNLOCKED, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"opening"}', + STATE_UNLOCKING, + ), ], ) async def test_controlling_non_default_state_via_topic_and_json_message( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "closed", - "state_locking": "closing", - "state_unlocked": "open", - "state_unlocking": "opening", - "value_template": "{{ value_json.val }}", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -264,13 +263,9 @@ async def test_controlling_non_default_state_via_topic_and_json_message( assert state.state is lock_state -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -282,10 +277,14 @@ async def test_sending_mqtt_commands_and_optimistic( "state_unlocked": "UNLOCKED", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -312,13 +311,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test sending commands with template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -333,10 +328,14 @@ async def test_sending_mqtt_commands_with_template( "state_unlocked": "UNLOCKED", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_with_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test sending commands with template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -373,30 +372,30 @@ async def test_sending_mqtt_commands_with_template( assert state.attributes.get(ATTR_ASSUMED_STATE) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lock.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "state_locked": "LOCKED", + "state_unlocked": "UNLOCKED", + "optimistic": True, + } + } + } + ], +) async def test_sending_mqtt_commands_and_explicit_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "LOCKED", - "state_unlocked": "UNLOCKED", - "optimistic": True, - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -423,29 +422,29 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lock.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_open": "OPEN", + "state_locked": "LOCKED", + "state_unlocked": "UNLOCKED", + } + } + } + ], +) async def test_sending_mqtt_commands_support_open_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test open function of the lock without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "payload_open": "OPEN", - "state_locked": "LOCKED", - "state_unlocked": "UNLOCKED", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -483,13 +482,9 @@ async def test_sending_mqtt_commands_support_open_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test open function of the lock without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -504,10 +499,14 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test open function of the lock without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -545,13 +544,9 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test function of the lock with state topics.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -568,10 +563,14 @@ async def test_sending_mqtt_commands_pessimistic( "state_jammed": "JAMMED", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_pessimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test function of the lock with state topics.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index c9bcd07f26..18d59f9867 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -2,28 +2,19 @@ from unittest.mock import patch +import pytest + from homeassistant.components import mqtt, sensor from homeassistant.const import EVENT_STATE_CHANGED, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.setup import async_setup_component from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) -async def test_availability_with_shared_state_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test the state is not changed twice. - - When an entity with a shared state_topic and availability_topic becomes available - The state should only change once. - """ - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -36,10 +27,20 @@ async def test_availability_with_shared_state_topic( "availability_template": "{{ value != '0' }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) +async def test_availability_with_shared_state_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the state is not changed twice. + + When an entity with a shared state_topic and availability_topic becomes available + The state should only change once. + """ + await mqtt_mock_entry_no_yaml_config() events = [] diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index eb2a640228..bd28d75ac9 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -30,7 +30,6 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -76,31 +75,30 @@ def number_platform_only(): yield -async def test_run_number_setup( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "device_class": "temperature", - "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT.value, "payload_reset": "reset!", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_number_setup( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload.""" + await mqtt_mock_entry_no_yaml_config() - async_fire_mqtt_message(hass, topic, "10") + async_fire_mqtt_message(hass, "test/state_number", "10") await hass.async_block_till_done() @@ -109,7 +107,7 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" - async_fire_mqtt_message(hass, topic, "20.5") + async_fire_mqtt_message(hass, "test/state_number", "20.5") await hass.async_block_till_done() @@ -118,7 +116,7 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" - async_fire_mqtt_message(hass, topic, "reset!") + async_fire_mqtt_message(hass, "test/state_number", "reset!") await hass.async_block_till_done() @@ -128,27 +126,27 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" -async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template.""" + topic = "test/state_number" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, '{"val":10}') @@ -172,11 +170,25 @@ async def test_value_template( assert state.state == "unknown" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number", + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT.value, + "name": "Test Number", + } + } + } + ], +) async def test_restore_native_value( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the stored native_value is restored.""" - topic = "test/number" RESTORE_DATA = { "native_max_value": None, # Ignored by MQTT number @@ -189,30 +201,28 @@ async def test_restore_native_value( mock_restore_cache_with_extra_data( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": topic, - "device_class": "temperature", - "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, - "name": "Test Number", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.state == "37.8" assert state.attributes.get(ATTR_ASSUMED_STATE) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number", + "name": "Test Number", + } + } + } + ], +) async def test_run_number_service_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in optimistic mode.""" topic = "test/number" @@ -228,20 +238,8 @@ async def test_run_number_service_optimistic( mock_restore_cache_with_extra_data( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": topic, - "name": "Test Number", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" @@ -287,8 +285,22 @@ async def test_run_number_service_optimistic( assert state.state == "42.1" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number", + "name": "Test Number", + "command_template": '{"number": {{ value }} }', + } + } + } + ], +) async def test_run_number_service_optimistic_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in optimistic mode and with a command_template.""" topic = "test/number" @@ -304,21 +316,7 @@ async def test_run_number_service_optimistic_with_command_template( mock_restore_cache_with_extra_data( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": topic, - "name": "Test Number", - "command_template": '{"number": {{ value }} }', - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" @@ -366,28 +364,28 @@ async def test_run_number_service_optimistic_with_command_template( assert state.state == "42.1" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number/set", + "state_topic": "test/number", + "name": "Test Number", + } + } + } + ], +) async def test_run_number_service( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/number/set" state_topic = "test/number" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, - "name": "Test Number", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "32") state = hass.states.get("number.test_number") @@ -404,29 +402,29 @@ async def test_run_number_service( assert state.state == "32" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number/set", + "state_topic": "test/number", + "name": "Test Number", + "command_template": '{"number": {{ value }} }', + } + } + } + ], +) async def test_run_number_service_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode and with a command_template.""" cmd_topic = "test/number/set" state_topic = "test/number" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, - "name": "Test Number", - "command_template": '{"number": {{ value }} }', - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "32") state = hass.states.get("number.test_number") @@ -732,29 +730,28 @@ async def test_entity_debug_info_message( ) -async def test_min_max_step_attributes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test min/max/step attributes.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "min": 5, "max": 110, "step": 20, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_min_max_step_attributes( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test min/max/step attributes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MIN) == 5 @@ -762,129 +759,180 @@ async def test_min_max_step_attributes( assert state.attributes.get(ATTR_STEP) == 20 -async def test_invalid_min_max_attributes( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test invalid min/max attributes.""" - topic = "test/number" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "min": 35, "max": 10, } } - }, - ) - + } + ], +) +async def test_invalid_min_max_attributes( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid min/max attributes.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert f"'{CONF_MAX}' must be > '{CONF_MIN}'" in caplog.text -async def test_default_mode( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test default mode.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_default_mode( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test default mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MODE) == "auto" -@pytest.mark.parametrize("mode", ("auto", "box", "slider")) +@pytest.mark.parametrize( + ("hass_config", "mode"), + [ + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "auto", + } + } + }, + "auto", + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "box", + } + } + }, + "box", + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "slider", + } + } + }, + "slider", + ), + ], +) async def test_mode( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, mode, ) -> None: """Test mode.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, - "name": "Test Number", - "mode": mode, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MODE) == mode -@pytest.mark.parametrize(("mode", "valid"), [("bleh", False), ("auto", True)]) -async def test_invalid_mode(hass: HomeAssistant, mode, valid) -> None: - """Test invalid mode.""" - topic = "test/number" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + ("hass_config", "valid"), + [ + ( { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", - "mode": mode, + "mode": "bleh", } } }, - ) - is valid - ) - - -async def test_mqtt_payload_not_a_number_warning( + False, + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "auto", + } + } + }, + True, + ), + ], +) +async def test_invalid_mode( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + valid: bool, ) -> None: - """Test warning for MQTT payload which is not a number.""" - topic = "test/number" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test invalid mode.""" + if valid: + await mqtt_mock_entry_no_yaml_config() + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_mqtt_payload_not_a_number_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test warning for MQTT payload which is not a number.""" + topic = "test/state_number" + + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, "not_a_number") @@ -893,30 +941,32 @@ async def test_mqtt_payload_not_a_number_warning( assert "Payload 'not_a_number' is not a Number" in caplog.text -async def test_mqtt_payload_out_of_range_error( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test error when MQTT payload is out of min/max range.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "min": 5, "max": 110, } } - }, - ) + } + ], +) +async def test_mqtt_payload_out_of_range_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when MQTT payload is out of min/max range.""" + topic = "test/state_number" + await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, "115.5") diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 3da5fd4f36..56350c90c0 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -7,7 +7,6 @@ import pytest from homeassistant.components import mqtt, scene from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -44,16 +43,9 @@ def scene_platform_only(): yield -async def test_sending_mqtt_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands.""" - fake_state = State("scene.test", STATE_UNKNOWN) - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { scene.DOMAIN: { @@ -62,10 +54,17 @@ async def test_sending_mqtt_commands( "payload_on": "beer on", }, } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands.""" + fake_state = State("scene.test", STATE_UNKNOWN) + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("scene.test") assert state.state == STATE_UNKNOWN From f2b4c95a04e38f53febf4793790b2e8d348f8bf5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:42:00 +0100 Subject: [PATCH 0103/1165] Prepare MQTT platform tests part7 (#90130) * Tests select * Tests sensor * Deduplicate test code --- tests/components/mqtt/test_select.py | 279 +++++------ tests/components/mqtt/test_sensor.py | 677 +++++++++++++++------------ 2 files changed, 521 insertions(+), 435 deletions(-) diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 3a639ecf08..bbda9c88de 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -1,4 +1,5 @@ """The tests for mqtt select component.""" +from collections.abc import Generator import copy import json from typing import Any @@ -21,7 +22,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType from .test_common import ( help_test_availability_when_connection_lost, @@ -74,27 +75,35 @@ def select_platform_only(): yield -async def test_run_select_setup( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, +def _test_run_select_setup_params( + topic: str, +) -> Generator[tuple[ConfigType, str], None]: + yield ( { mqtt.DOMAIN: { select.DOMAIN: { "state_topic": topic, - "command_topic": topic, + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], } } }, + topic, ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + + +@pytest.mark.parametrize( + ("hass_config", "topic"), + _test_run_select_setup_params("test/select_stat"), +) +async def test_run_select_setup( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, +) -> None: + """Test that it fetches the given payload.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, "milk") @@ -111,44 +120,43 @@ async def test_run_select_setup( assert state.state == "beer" -async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/select_stat", + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template.""" + await mqtt_mock_entry_no_yaml_config() - async_fire_mqtt_message(hass, topic, '{"val":"milk"}') + async_fire_mqtt_message(hass, "test/select_stat", '{"val":"milk"}') await hass.async_block_till_done() state = hass.states.get("select.test_select") assert state.state == "milk" - async_fire_mqtt_message(hass, topic, '{"val":"beer"}') + async_fire_mqtt_message(hass, "test/select_stat", '{"val":"beer"}') await hass.async_block_till_done() state = hass.states.get("select.test_select") assert state.state == "beer" - async_fire_mqtt_message(hass, topic, '{"val": null}') + async_fire_mqtt_message(hass, "test/select_stat", '{"val": null}') await hass.async_block_till_done() @@ -156,30 +164,28 @@ async def test_value_template( assert state.state == STATE_UNKNOWN -async def test_run_select_service_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that set_value service works in optimistic mode.""" - topic = "test/select" - - fake_state = State("select.test_select", "milk") - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "command_topic": topic, + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_select_service_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that set_value service works in optimistic mode.""" + fake_state = State("select.test_select", "milk") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("select.test_select") assert state.state == "milk" @@ -192,37 +198,35 @@ async def test_run_select_service_optimistic( blocking=True, ) - mqtt_mock.async_publish.assert_called_once_with(topic, "beer", 0, False) + mqtt_mock.async_publish.assert_called_once_with("test/select_cmd", "beer", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("select.test_select") assert state.state == "beer" -async def test_run_select_service_optimistic_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that set_value service works in optimistic mode and with a command_template.""" - topic = "test/select" - - fake_state = State("select.test_select", "milk") - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "command_topic": topic, + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], "command_template": '{"option": "{{ value }}"}', } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_select_service_optimistic_with_command_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that set_value service works in optimistic mode and with a command_template.""" + fake_state = State("select.test_select", "milk") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("select.test_select") assert state.state == "milk" @@ -236,36 +240,36 @@ async def test_run_select_service_optimistic_with_command_template( ) mqtt_mock.async_publish.assert_called_once_with( - topic, '{"option": "beer"}', 0, False + "test/select_cmd", '{"option": "beer"}', 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("select.test_select") assert state.state == "beer" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + select.DOMAIN: { + "command_topic": "test/select/set", + "state_topic": "test/select", + "name": "Test Select", + "options": ["milk", "beer"], + } + } + } + ], +) async def test_run_select_service( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/select/set" state_topic = "test/select" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - select.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, - "name": "Test Select", - "options": ["milk", "beer"], - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "beer") state = hass.states.get("select.test_select") @@ -282,30 +286,30 @@ async def test_run_select_service( assert state.state == "beer" -async def test_run_select_service_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that set_value service works in non optimistic mode and with a command_template.""" - cmd_topic = "test/select/set" - state_topic = "test/select" - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, + "command_topic": "test/select/set", + "state_topic": "test/select", "name": "Test Select", "options": ["milk", "beer"], "command_template": '{"option": "{{ value }}"}', } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_select_service_with_command_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that set_value service works in non optimistic mode and with a command_template.""" + cmd_topic = "test/select/set" + state_topic = "test/select" + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "beer") state = hass.states.get("select.test_select") @@ -609,60 +613,65 @@ async def test_entity_debug_info_message( ) -@pytest.mark.parametrize("options", [["milk", "beer"], ["milk"], []]) +def _test_options_attributes_options_config( + request: tuple[list[str]], +) -> Generator[tuple[ConfigType, list[str]], None]: + for option in request: + yield ( + { + mqtt.DOMAIN: { + select.DOMAIN: { + "command_topic": "test/select/set", + "state_topic": "test/select", + "name": "Test select", + "options": option, + } + } + }, + option, + ) + + +@pytest.mark.parametrize( + ("hass_config", "options"), + _test_options_attributes_options_config((["milk", "beer"], ["milk"], [])), +) async def test_options_attributes( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - options, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + options: list[str], ) -> None: """Test options attribute.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - select.DOMAIN: { - "state_topic": topic, - "command_topic": topic, - "name": "Test select", - "options": options, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("select.test_select") assert state.attributes.get(ATTR_OPTIONS) == options -async def test_mqtt_payload_not_an_option_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test warning for MQTT payload which is not a valid option.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/select_stat", + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_mqtt_payload_not_an_option_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test warning for MQTT payload which is not a valid option.""" + await mqtt_mock_entry_no_yaml_config() - async_fire_mqtt_message(hass, topic, "öl") + async_fire_mqtt_message(hass, "test/select_stat", "öl") await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 6889069c8c..01c897a9d8 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -18,12 +18,13 @@ from homeassistant.const import ( Platform, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -82,13 +83,9 @@ def sensor_platform_only(): yield -async def test_setting_sensor_value_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -98,10 +95,14 @@ async def test_setting_sensor_value_via_mqtt_message( "suggested_display_precision": 1, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", "100.22") state = hass.states.get("sensor.test") @@ -113,64 +114,118 @@ async def test_setting_sensor_value_via_mqtt_message( @pytest.mark.parametrize( - ("device_class", "native_value", "state_value", "log"), + ("hass_config", "device_class", "native_value", "state_value", "log"), [ - (sensor.SensorDeviceClass.DATE, "2021-11-18", "2021-11-18", False), - (sensor.SensorDeviceClass.DATE, "invalid", STATE_UNKNOWN, True), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.DATE},), + ), + sensor.SensorDeviceClass.DATE, + "2021-11-18", + "2021-11-18", + False, + ), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.DATE},), + ), + sensor.SensorDeviceClass.DATE, + "invalid", + STATE_UNKNOWN, + True, + ), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18T20:25:00+00:00", "2021-11-18T20:25:00+00:00", False, ), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18 20:25:00+00:00", "2021-11-18T20:25:00+00:00", False, ), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18 20:25:00+01:00", "2021-11-18T19:25:00+00:00", False, ), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-13-18T35:25:00+00:00", STATE_UNKNOWN, True, ), - (sensor.SensorDeviceClass.TIMESTAMP, "invalid", STATE_UNKNOWN, True), - (sensor.SensorDeviceClass.ENUM, "some_value", "some_value", False), - (None, "some_value", "some_value", False), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), + sensor.SensorDeviceClass.TIMESTAMP, + "invalid", + STATE_UNKNOWN, + True, + ), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.ENUM},), + ), + sensor.SensorDeviceClass.ENUM, + "some_value", + "some_value", + False, + ), + ( + help_custom_config( + sensor.DOMAIN, DEFAULT_CONFIG, ({"device_class": None},) + ), + None, + "some_value", + "some_value", + False, + ), ], ) async def test_setting_sensor_native_value_handling_via_mqtt_message( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - device_class, - native_value, - state_value, - log, + device_class: sensor.SensorDeviceClass | None, + native_value: str, + state_value: str, + log: bool, ) -> None: """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_topic": "test-topic", - "device_class": device_class, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", native_value) state = hass.states.get("sensor.test") @@ -180,14 +235,9 @@ async def test_setting_sensor_native_value_handling_via_mqtt_message( assert log == ("Invalid state message" in caplog.text) -async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test the setting of a numeric sensor value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -198,10 +248,16 @@ async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( "unit_of_measurement": "W", } } - }, - ) + } + ], +) +async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of a numeric sensor value via MQTT.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # float value async_fire_mqtt_message(hass, "test-topic", '{ "power": 45.3, "current": 5.24 }') @@ -235,15 +291,9 @@ async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( assert state.state == "21" -async def test_setting_sensor_value_expires_availability_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -254,10 +304,14 @@ async def test_setting_sensor_value_expires_availability_topic( "availability_topic": "availability-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires_availability_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE @@ -268,18 +322,12 @@ async def test_setting_sensor_value_expires_availability_topic( state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE - await expires_helper(hass, caplog) + await expires_helper(hass) -async def test_setting_sensor_value_expires( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -290,19 +338,23 @@ async def test_setting_sensor_value_expires( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() # State should be unavailable since expire_after is defined and > 0 state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE - await expires_helper(hass, caplog) + await expires_helper(hass) -async def expires_helper(hass: HomeAssistant, caplog) -> None: +async def expires_helper(hass: HomeAssistant) -> None: """Run the basic expiry code.""" realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) @@ -353,13 +405,9 @@ async def expires_helper(hass: HomeAssistant, caplog) -> None: assert state.state == STATE_UNAVAILABLE -async def test_setting_sensor_value_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -368,10 +416,14 @@ async def test_setting_sensor_value_via_mqtt_json_message( "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT with JSON payload.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", '{ "val": "100" }') state = hass.states.get("sensor.test") @@ -385,13 +437,9 @@ async def test_setting_sensor_value_via_mqtt_json_message( assert state.state == "" -async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_state( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT with fall back to current state.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -400,10 +448,14 @@ async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_st "value_template": "{{ value_json.val | is_defined }}-{{ value_json.par }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_state( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT with fall back to current state.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "test-topic", '{ "val": "valcontent", "par": "parcontent" }' @@ -418,15 +470,9 @@ async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_st assert state.state == "valcontent-parcontent" -async def test_setting_sensor_last_reset_via_mqtt_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -437,10 +483,16 @@ async def test_setting_sensor_last_reset_via_mqtt_message( "last_reset_topic": "last-reset-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_last_reset_via_mqtt_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the last_reset property via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") state = hass.states.get("sensor.test") @@ -452,17 +504,9 @@ async def test_setting_sensor_last_reset_via_mqtt_message( ) -@pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) -async def test_setting_sensor_bad_last_reset_via_mqtt_message( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - datestring, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -473,10 +517,18 @@ async def test_setting_sensor_bad_last_reset_via_mqtt_message( "last_reset_topic": "last-reset-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +@pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) +async def test_setting_sensor_bad_last_reset_via_mqtt_message( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + datestring, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of the last_reset property via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", datestring) state = hass.states.get("sensor.test") @@ -484,13 +536,9 @@ async def test_setting_sensor_bad_last_reset_via_mqtt_message( assert "Invalid last_reset message" in caplog.text -async def test_setting_sensor_empty_last_reset_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -501,23 +549,23 @@ async def test_setting_sensor_empty_last_reset_via_mqtt_message( "last_reset_topic": "last-reset-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_empty_last_reset_via_mqtt_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the last_reset property via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", "") state = hass.states.get("sensor.test") assert state.attributes.get("last_reset") is None -async def test_setting_sensor_last_reset_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -529,10 +577,14 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message( "last_reset_value_template": "{{ value_json.last_reset }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_last_reset_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT with JSON payload.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "last-reset-topic", '{ "last_reset": "2020-01-02 08:11:00" }' @@ -541,35 +593,44 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message( assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" -@pytest.mark.parametrize("extra", [{}, {"last_reset_topic": "test-topic"}]) -async def test_setting_sensor_last_reset_via_mqtt_json_message_2( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - extra, -) -> None: - """Test the setting of the value via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { - **{ - "name": "test", - "state_class": "total", - "state_topic": "test-topic", - "unit_of_measurement": "kWh", - "value_template": "{{ value_json.value | float / 60000 }}", - "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", - }, - **extra, - } + "name": "test", + "state_class": "total", + "state_topic": "test-topic", + "unit_of_measurement": "kWh", + "value_template": "{{ value_json.value | float / 60000 }}", + "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", + }, } }, - ) + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_class": "total", + "state_topic": "test-topic", + "unit_of_measurement": "kWh", + "value_template": "{{ value_json.value | float / 60000 }}", + "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", + "last_reset_topic": "test-topic", + }, + } + }, + ], +) +async def test_setting_sensor_last_reset_via_mqtt_json_message_2( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT with JSON payload.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, @@ -586,13 +647,9 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message_2( ) -async def test_force_update_disabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -601,15 +658,19 @@ async def test_force_update_disabled( "unit_of_measurement": "fav unit", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_disabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() - events = [] + events: list[Event] = [] @callback - def test_callback(event) -> None: + def test_callback(event: Event) -> None: events.append(event) hass.bus.async_listen(EVENT_STATE_CHANGED, test_callback) @@ -623,13 +684,9 @@ async def test_force_update_disabled( assert len(events) == 1 -async def test_force_update_enabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -639,15 +696,19 @@ async def test_force_update_enabled( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_enabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() - events = [] + events: list[Event] = [] @callback - def test_callback(event) -> None: + def test_callback(event: Event) -> None: events.append(event) hass.bus.async_listen(EVENT_STATE_CHANGED, test_callback) @@ -747,13 +808,9 @@ async def test_discovery_update_availability( ) -async def test_invalid_device_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test device_class option with invalid value.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -762,22 +819,25 @@ async def test_invalid_device_class( "device_class": "foobarnotreal", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() - - state = hass.states.get("sensor.test") - assert state is None - - -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test device_class option with valid values.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test device_class option with invalid value.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert ( + "Invalid config for [mqtt]: expected SensorDeviceClass or one of" in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: [ @@ -794,10 +854,14 @@ async def test_valid_device_class( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test device_class option with valid values.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test_1") assert state.attributes["device_class"] == "temperature" @@ -807,13 +871,9 @@ async def test_valid_device_class( assert "device_class" not in state.attributes -async def test_invalid_state_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state_class option with invalid value.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -822,22 +882,25 @@ async def test_invalid_state_class( "state_class": "foobarnotreal", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() - - state = hass.states.get("sensor.test") - assert state is None - - -async def test_valid_state_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_invalid_state_class( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test state_class option with valid values.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test state_class option with invalid value.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert ( + "Invalid config for [mqtt]: expected SensorStateClass or one of" in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: [ @@ -854,10 +917,14 @@ async def test_valid_state_class( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_state_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state_class option with valid values.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test_1") assert state.attributes["state_class"] == "measurement" @@ -1237,13 +1304,9 @@ async def test_entity_category( ) -async def test_value_template_with_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the access to attributes in value_template via the entity_id.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -1258,10 +1321,14 @@ async def test_value_template_with_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template_with_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the access to attributes in value_template via the entity_id.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", "100") state = hass.states.get("sensor.test") @@ -1279,38 +1346,43 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "name": "test1", + "expire_after": 30, + "state_topic": "test-topic1", + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT.value, + }, + { + "name": "test2", + "expire_after": 5, + "state_topic": "test-topic2", + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS.value, + }, + ), + ) + ], +) async def test_cleanup_triggers_and_restoring_state( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, freezer: FrozenDateTimeFactory, + hass_config: ConfigType, ) -> None: """Test cleanup old triggers at reloading and restoring the state.""" - domain = sensor.DOMAIN - config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config1["name"] = "test1" - config1["expire_after"] = 30 - config1["state_topic"] = "test-topic1" - config1["device_class"] = "temperature" - config1["unit_of_measurement"] = UnitOfTemperature.FAHRENHEIT.value - - config2 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config2["name"] = "test2" - config2["expire_after"] = 5 - config2["state_topic"] = "test-topic2" - config2["device_class"] = "temperature" - config2["unit_of_measurement"] = UnitOfTemperature.CELSIUS.value - freezer.move_to("2022-02-02 12:01:00+01:00") - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {domain: [config1, config2]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic1", "100") state = hass.states.get("sensor.test1") assert state.state == "38" # 100 °F -> 38 °C @@ -1321,9 +1393,7 @@ async def test_cleanup_triggers_and_restoring_state( freezer.move_to("2022-02-02 12:01:10+01:00") - await help_test_reload_with_config( - hass, caplog, tmp_path, {mqtt.DOMAIN: {domain: [config1, config2]}} - ) + await help_test_reload_with_config(hass, caplog, tmp_path, hass_config) await hass.async_block_till_done() state = hass.states.get("sensor.test1") @@ -1341,19 +1411,30 @@ async def test_cleanup_triggers_and_restoring_state( assert state.state == "201" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "name": "test3", + "expire_after": 10, + "state_topic": "test-topic3", + }, + ), + ) + ], +) async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" freezer.move_to("2022-02-02 12:02:00+01:00") - domain = sensor.DOMAIN - config3 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config3["name"] = "test3" - config3["expire_after"] = 10 - config3["state_topic"] = "test-topic3" fake_state = State( "sensor.test3", "300", @@ -1363,11 +1444,7 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( fake_extra_data = MagicMock() mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {domain: config3}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test3") assert state.state == STATE_UNAVAILABLE From 1224b1aff607957b3fd1baae2c36adae0667069c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:42:23 +0100 Subject: [PATCH 0104/1165] Use helper on tests MQTT fan platform (#90196) Use helper on tests fan --- tests/components/mqtt/test_fan.py | 118 ++++++++++++++++-------------- 1 file changed, 62 insertions(+), 56 deletions(-) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 9882b9102e..3e3f6219b0 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -34,6 +34,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -226,36 +227,37 @@ async def test_controlling_state_via_topic( @pytest.mark.parametrize( "hass_config", [ - { - mqtt.DOMAIN: { - fan.DOMAIN: [ - { - "name": "test1", + help_custom_config( + fan.DOMAIN, + { + mqtt.DOMAIN: { + fan.DOMAIN: { "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic1", - "percentage_command_topic": "percentage-command-topic1", - "speed_range_min": 1, - "speed_range_max": 100, - }, - { - "name": "test2", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic2", - "percentage_command_topic": "percentage-command-topic2", - "speed_range_min": 1, - "speed_range_max": 200, - }, - { - "name": "test3", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic3", - "percentage_command_topic": "percentage-command-topic3", - "speed_range_min": 81, - "speed_range_max": 1023, - }, - ] - } - } + "percentage_command_topic": "percentage-command-topic", + } + } + }, + ( + { + "name": "test1", + "percentage_state_topic": "percentage-state-topic1", + "speed_range_min": 1, + "speed_range_max": 100, + }, + { + "name": "test2", + "percentage_state_topic": "percentage-state-topic2", + "speed_range_min": 1, + "speed_range_max": 200, + }, + { + "name": "test3", + "percentage_state_topic": "percentage-state-topic3", + "speed_range_min": 81, + "speed_range_max": 1023, + }, + ), + ), ], ) async def test_controlling_state_via_topic_with_different_speed_range( @@ -672,36 +674,40 @@ async def test_sending_mqtt_commands_and_optimistic( @pytest.mark.parametrize( "hass_config", [ - { - mqtt.DOMAIN: { - fan.DOMAIN: [ - { + help_custom_config( + fan.DOMAIN, + { + mqtt.DOMAIN: { + fan.DOMAIN: { "name": "test1", "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic1", - "percentage_command_topic": "percentage-command-topic1", + "percentage_state_topic": "percentage-state-topic", "speed_range_min": 1, "speed_range_max": 3, - }, - { - "name": "test2", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic2", - "percentage_command_topic": "percentage-command-topic2", - "speed_range_min": 1, - "speed_range_max": 200, - }, - { - "name": "test3", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic3", - "percentage_command_topic": "percentage-command-topic3", - "speed_range_min": 81, - "speed_range_max": 1023, - }, - ] - } - } + } + } + }, + ( + { + "name": "test1", + "percentage_command_topic": "percentage-command-topic1", + "speed_range_min": 1, + "speed_range_max": 3, + }, + { + "name": "test2", + "percentage_command_topic": "percentage-command-topic2", + "speed_range_min": 1, + "speed_range_max": 200, + }, + { + "name": "test3", + "percentage_command_topic": "percentage-command-topic3", + "speed_range_min": 81, + "speed_range_max": 1023, + }, + ), + ), ], ) async def test_sending_mqtt_commands_with_alternate_speed_range( @@ -1460,7 +1466,7 @@ async def test_attributes( } }, True, - 0, + fan.FanEntityFeature(0), ), ( "test2", From 34324c98de241b0abc2bbba5badcd034917145cc Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Fri, 24 Mar 2023 02:44:35 -0500 Subject: [PATCH 0105/1165] Rename create_api_object to be private (#90187) --- homeassistant/components/control4/light.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 2c92010901..5748664119 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -175,7 +175,7 @@ class Control4Light(Control4Entity, LightEntity): self._attr_color_mode = ColorMode.ONOFF self._attr_supported_color_modes = {ColorMode.ONOFF} - def create_api_object(self): + def _create_api_object(self): """Create a pyControl4 device object. This exists so the director token used is always the latest one, without needing to re-init the entire entity. @@ -203,7 +203,7 @@ class Control4Light(Control4Entity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - c4_light = self.create_api_object() + c4_light = self._create_api_object() if self._is_dimmer: if ATTR_TRANSITION in kwargs: transition_length = kwargs[ATTR_TRANSITION] * 1000 @@ -226,7 +226,7 @@ class Control4Light(Control4Entity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - c4_light = self.create_api_object() + c4_light = self._create_api_object() if self._is_dimmer: if ATTR_TRANSITION in kwargs: transition_length = kwargs[ATTR_TRANSITION] * 1000 From 7364e6ecb3194f44482ea1b34fe14766943c1f75 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 09:38:43 +0100 Subject: [PATCH 0106/1165] Remove incorrect parametrize decorator (#90219) --- tests/components/mqtt/test_cover.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index dd28d0919b..3fb008b218 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -2235,7 +2235,6 @@ async def test_tilt_position_altered_range( ) -@pytest.mark.parametrize("hass_config", []) async def test_find_percentage_in_range_defaults(hass: HomeAssistant) -> None: """Test find percentage in range with default range.""" mqtt_cover = MqttCover( From ee74e21541286cf1a29fc505de335b244d5924b1 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 24 Mar 2023 10:06:09 +0100 Subject: [PATCH 0107/1165] Rework UniFi wireless client "wired bug" logic (#89757) --- homeassistant/components/unifi/__init__.py | 42 +++++++++++++++---- homeassistant/components/unifi/controller.py | 34 +-------------- .../components/unifi/device_tracker.py | 2 +- homeassistant/components/unifi/sensor.py | 12 +++--- tests/components/unifi/test_controller.py | 41 +----------------- tests/components/unifi/test_init.py | 12 ++---- 6 files changed, 47 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index adaa7c977f..d6405d1171 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -1,5 +1,7 @@ """Integration to UniFi Network and its various features.""" +from aiounifi.models.client import Client + from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback @@ -91,33 +93,55 @@ def async_remove_poe_client_entities( class UnifiWirelessClients: """Class to store clients known to be wireless. - This is needed since wireless devices going offline might get marked as wired by UniFi. + This is needed since wireless devices going offline + might get marked as wired by UniFi. """ def __init__(self, hass: HomeAssistant) -> None: """Set up client storage.""" self.hass = hass - self.data: dict[str, dict[str, list[str]]] = {} + self.data: dict[str, dict[str, list[str]] | list[str]] = {} + self.wireless_clients: set[str] = set() self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY) async def async_load(self) -> None: """Load data from file.""" if (data := await self._store.async_load()) is not None: self.data = data + if "wireless_clients" not in data: + data["wireless_clients"] = [ + obj_id + for config_entry in data + for obj_id in data[config_entry]["wireless_devices"] + ] + self.wireless_clients.update(data["wireless_clients"]) @callback - def get_data(self, config_entry: ConfigEntry) -> set[str]: - """Get data related to a specific controller.""" - data = self.data.get(config_entry.entry_id, {"wireless_devices": []}) - return set(data["wireless_devices"]) + def is_wireless(self, client: Client) -> bool: + """Is client known to be wireless. + + Store if client is wireless and not known. + """ + if not client.is_wired and client.mac not in self.wireless_clients: + self.wireless_clients.add(client.mac) + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + return client.mac in self.wireless_clients @callback - def update_data(self, data: set[str], config_entry: ConfigEntry) -> None: + def update_clients(self, clients: set[Client]) -> None: """Update data and schedule to save to file.""" - self.data[config_entry.entry_id] = {"wireless_devices": list(data)} + self.wireless_clients.update( + {client.mac for client in clients if not client.is_wired} + ) self._store.async_delay_save(self._data_to_save, SAVE_DELAY) @callback - def _data_to_save(self) -> dict[str, dict[str, list[str]]]: + def _data_to_save(self) -> dict[str, dict[str, list[str]] | list[str]]: """Return data of UniFi wireless clients to store in a file.""" + self.data["wireless_clients"] = list(self.wireless_clients) return self.data + + def __contains__(self, obj_id: int | str) -> bool: + """Validate membership of item ID.""" + return obj_id in self.wireless_clients diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 8a047606c6..a5f3c4d772 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -10,8 +10,6 @@ from typing import Any from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent -from aiounifi.interfaces.messages import DATA_EVENT -from aiounifi.models.event import EventKey from aiounifi.websocket import WebsocketSignal, WebsocketState import async_timeout @@ -86,8 +84,7 @@ class UniFiController: api.callback = self.async_unifi_signalling_callback self.available = True - self.progress = None - self.wireless_clients = None + self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] self.site_id: str = "" self._site_name = None @@ -247,15 +244,6 @@ class UniFiController: else: LOGGER.info("Connected to UniFi Network") - elif signal == WebsocketSignal.DATA and DATA_EVENT in data: - for event in data[DATA_EVENT]: - if event.key in ( - EventKey.WIRELESS_CLIENT_CONNECTED, - EventKey.WIRELESS_GUEST_CONNECTED, - ): - self.update_wireless_clients() - break - @property def signal_reachable(self) -> str: """Integration specific event to signal a change in connection status.""" @@ -271,22 +259,6 @@ class UniFiController: """Event specific per UniFi device tracker to signal new heartbeat missed.""" return "unifi-heartbeat-missed" - def update_wireless_clients(self): - """Update set of known to be wireless clients.""" - new_wireless_clients = set() - - for client_id in self.api.clients: - if ( - client_id not in self.wireless_clients - and not self.api.clients[client_id].is_wired - ): - new_wireless_clients.add(client_id) - - if new_wireless_clients: - self.wireless_clients |= new_wireless_clients - unifi_wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS] - unifi_wireless_clients.update_data(self.wireless_clients, self.config_entry) - async def initialize(self): """Set up a UniFi Network instance.""" await self.api.initialize() @@ -326,9 +298,7 @@ class UniFiController: client.mac, ) - wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS] - self.wireless_clients = wireless_clients.get_data(self.config_entry) - self.update_wireless_clients() + self.wireless_clients.update_clients(set(self.api.clients.values())) self.config_entry.add_update_listener(self.async_config_entry_updated) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index a5b153d7f3..f891416c63 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -105,7 +105,7 @@ def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bo """Check if device object is disabled.""" client = controller.api.clients[obj_id] - if client.is_wired != (obj_id not in controller.wireless_clients): + if controller.wireless_clients.is_wireless(client) and client.is_wired: if not controller.option_ignore_wired_bug: return False # Wired bug in action diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 05598589fe..420fc3803c 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -45,17 +45,17 @@ from .entity import ( @callback def async_client_rx_value_fn(controller: UniFiController, client: Client) -> float: """Calculate receiving data transfer value.""" - if client.mac not in controller.wireless_clients: - return client.wired_rx_bytes_r / 1000000 - return client.rx_bytes_r / 1000000 + if controller.wireless_clients.is_wireless(client): + return client.rx_bytes_r / 1000000 + return client.wired_rx_bytes_r / 1000000 @callback def async_client_tx_value_fn(controller: UniFiController, client: Client) -> float: """Calculate transmission data transfer value.""" - if client.mac not in controller.wireless_clients: - return client.wired_tx_bytes_r / 1000000 - return client.tx_bytes_r / 1000000 + if controller.wireless_clients.is_wireless(client): + return client.tx_bytes_r / 1000000 + return client.wired_tx_bytes_r / 1000000 @callback diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 931c0fccdf..e3efaef915 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -6,8 +6,6 @@ from http import HTTPStatus from unittest.mock import Mock, patch import aiounifi -from aiounifi.models.event import EventKey -from aiounifi.models.message import MessageKey from aiounifi.websocket import WebsocketState import pytest @@ -182,8 +180,8 @@ async def setup_unifi_integration( config_entry.add_to_hass(hass) if known_wireless_clients: - hass.data[UNIFI_WIRELESS_CLIENTS].update_data( - known_wireless_clients, config_entry + hass.data[UNIFI_WIRELESS_CLIENTS].wireless_clients.update( + known_wireless_clients ) if aioclient_mock: @@ -383,41 +381,6 @@ async def test_connection_state_signalling( assert hass.states.get("device_tracker.client").state == "home" -async def test_wireless_client_event_calls_update_wireless_devices( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket -) -> None: - """Call update_wireless_devices method when receiving wireless client event.""" - client_1_dict = { - "essid": "ssid", - "disabled": False, - "hostname": "client_1", - "ip": "10.0.0.4", - "is_wired": False, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:01", - } - await setup_unifi_integration( - hass, - aioclient_mock, - clients_response=[client_1_dict], - known_wireless_clients=(client_1_dict["mac"],), - ) - - with patch( - "homeassistant.components.unifi.controller.UniFiController.update_wireless_clients", - return_value=None, - ) as wireless_clients_mock: - event = { - "datetime": "2020-01-20T19:37:04Z", - "user": "00:00:00:00:00:01", - "key": EventKey.WIRELESS_CLIENT_CONNECTED.value, - "msg": "User[11:22:33:44:55:66] has connected to WLAN", - "time": 1579549024893, - } - mock_unifi_websocket(message=MessageKey.EVENT, data=event) - assert wireless_clients_mock.assert_called_once - - async def test_reconnect_mechanism( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket ) -> None: diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index cb232445eb..cce26ac84c 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -89,19 +89,13 @@ async def test_wireless_clients( "is_wired": False, "mac": "00:00:00:00:00:02", } - config_entry = await setup_unifi_integration( + await setup_unifi_integration( hass, aioclient_mock, clients_response=[client_1, client_2] ) await flush_store(hass.data[unifi.UNIFI_WIRELESS_CLIENTS]._store) - for mac in [ + assert sorted(hass_storage[unifi.STORAGE_KEY]["data"]["wireless_clients"]) == [ "00:00:00:00:00:00", "00:00:00:00:00:01", "00:00:00:00:00:02", - ]: - assert ( - mac - in hass_storage[unifi.STORAGE_KEY]["data"][config_entry.entry_id][ - "wireless_devices" - ] - ) + ] From 31575799926e3f6b9421fef2466643bb07f3b005 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Fri, 24 Mar 2023 12:59:59 +0100 Subject: [PATCH 0108/1165] Remove duplicate code in livisi coordinator (#90227) * Simplify coordinator * remove window sensor specific code (isOpen) * parameter order, type hinta * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/livisi/climate.py | 13 ++-- .../components/livisi/coordinator.py | 76 +++++-------------- homeassistant/components/livisi/switch.py | 4 +- 3 files changed, 28 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index a6680a19af..3a0a219d94 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -99,14 +99,15 @@ class LivisiClimate(LivisiEntity, ClimateEntity): await super().async_added_to_hass() - target_temperature = await self.coordinator.async_get_vrcc_target_temperature( - self._target_temperature_capability + target_temperature = await self.coordinator.async_get_device_state( + self._target_temperature_capability, + "setpointTemperature" if self.coordinator.is_avatar else "pointTemperature", ) - temperature = await self.coordinator.async_get_vrcc_temperature( - self._temperature_capability + temperature = await self.coordinator.async_get_device_state( + self._temperature_capability, "temperature" ) - humidity = await self.coordinator.async_get_vrcc_humidity( - self._humidity_capability + humidity = await self.coordinator.async_get_device_state( + self._humidity_capability, "humidity" ) if temperature is None: self._attr_current_temperature = None diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index e6c29f7151..58124dfa04 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -58,6 +58,10 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): except ClientConnectorError as exc: raise UpdateFailed("Failed to get LIVISI the devices") from exc + def _async_dispatcher_send(self, event: str, source: str, data: Any) -> None: + if data is not None: + async_dispatcher_send(self.hass, f"{event}_{source}", data) + async def async_setup(self) -> None: """Set up the Livisi Smart Home Controller.""" if not self.aiolivisi.livisi_connection_data: @@ -83,44 +87,14 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Set the discovered devices list.""" return await self.aiolivisi.async_get_devices() - async def async_get_pss_state(self, capability: str) -> bool | None: - """Set the PSS state.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + async def async_get_device_state(self, capability: str, key: str) -> Any | None: + """Get state from livisi devices.""" + response: dict[str, Any] = await self.aiolivisi.async_get_device_state( capability[1:] ) if response is None: return None - on_state = response["onState"] - return on_state["value"] - - async def async_get_vrcc_target_temperature(self, capability: str) -> float | None: - """Get the target temperature of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - if self.is_avatar: - return response["setpointTemperature"]["value"] - return response["pointTemperature"]["value"] - - async def async_get_vrcc_temperature(self, capability: str) -> float | None: - """Get the temperature of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - return response["temperature"]["value"] - - async def async_get_vrcc_humidity(self, capability: str) -> int | None: - """Get the humidity of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - return response["humidity"]["value"] + return response.get(key, {}).get("value") async def async_set_all_rooms(self) -> None: """Set the room list.""" @@ -132,34 +106,20 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): def on_data(self, event_data: LivisiEvent) -> None: """Define a handler to fire when the data is received.""" - if event_data.onState is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_STATE_CHANGE}_{event_data.source}", - event_data.onState, - ) - if event_data.vrccData is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_STATE_CHANGE}_{event_data.source}", - event_data.vrccData, - ) - if event_data.isReachable is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{event_data.source}", - event_data.isReachable, - ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.onState + ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.vrccData + ) + self._async_dispatcher_send( + LIVISI_REACHABILITY_CHANGE, event_data.source, event_data.isReachable + ) async def on_close(self) -> None: """Define a handler to fire when the websocket is closed.""" for device_id in self.devices: - is_reachable: bool = False - async_dispatcher_send( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{device_id}", - is_reachable, - ) + self._async_dispatcher_send(LIVISI_REACHABILITY_CHANGE, device_id, False) await self.websocket.connect(self.on_data, self.on_close, self.port) diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index 1a5789ea24..2c5a2b5137 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -81,7 +81,9 @@ class LivisiSwitch(LivisiEntity, SwitchEntity): """Register callbacks.""" await super().async_added_to_hass() - response = await self.coordinator.async_get_pss_state(self._capability_id) + response = await self.coordinator.async_get_device_state( + self._capability_id, "onState" + ) if response is None: self._attr_is_on = False self._attr_available = False From a66bef6fdf34536fb546754d48ff023c13ed1fd7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Mar 2023 13:55:17 +0100 Subject: [PATCH 0109/1165] Bump actions/checkout from 3.4.0 to 3.5.0 (#90215) Bumps [actions/checkout](https://github.com/actions/checkout) from 3.4.0 to 3.5.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3.4.0...v3.5.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 12 +++++------ .github/workflows/ci.yaml | 32 +++++++++++++++--------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 6 +++--- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index bc21a2e3c7..ff53757bdd 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 with: fetch-depth: 0 @@ -67,7 +67,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 @@ -105,7 +105,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -249,7 +249,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set build additional args run: | @@ -292,7 +292,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -331,7 +331,7 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Login to DockerHub if: matrix.registry == 'homeassistant' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0dafda421..f4e04059d1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -79,7 +79,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -203,7 +203,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -248,7 +248,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -294,7 +294,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -343,7 +343,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -381,7 +381,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -487,7 +487,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -555,7 +555,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -587,7 +587,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -620,7 +620,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -664,7 +664,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -730,7 +730,7 @@ jobs: name: Run pip check ${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -783,7 +783,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -909,7 +909,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -1017,7 +1017,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -1093,7 +1093,7 @@ jobs: - pytest steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index b8cbd9204b..86bfa5f9bb 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 1086738283..63069b86ef 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -22,7 +22,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Get information id: info @@ -82,7 +82,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -119,7 +119,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download env_file uses: actions/download-artifact@v3 From 8149652f9f3f808261c01e8a43b1c692f2bae084 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Fri, 24 Mar 2023 09:20:37 -0400 Subject: [PATCH 0110/1165] Move qbittorrent constants to const.py (#90201) * move qbittorrent constants to const.py * move SENSOR_TYPE_* consts back to sensors.py --- homeassistant/components/qbittorrent/const.py | 3 +++ homeassistant/components/qbittorrent/sensor.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/qbittorrent/const.py diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py new file mode 100644 index 0000000000..5f9ad42f7f --- /dev/null +++ b/homeassistant/components/qbittorrent/const.py @@ -0,0 +1,3 @@ +"""Constants for qBittorrent.""" + +DEFAULT_NAME = "qBittorrent" diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 26605a8765..bee7a5d61a 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -28,14 +28,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DEFAULT_NAME + _LOGGER = logging.getLogger(__name__) SENSOR_TYPE_CURRENT_STATUS = "current_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" -DEFAULT_NAME = "qBittorrent" - SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_CURRENT_STATUS, From 4c45c3c63bc164210112c111bbb7b00afef64548 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 03:39:55 -1000 Subject: [PATCH 0111/1165] Add a faster query for get_last_state_changes when the number of states is 1 (#90211) * Add a faster query for get_last_state_changes when the number of states is 1 related issue #90113 * Add a faster query for get_last_state_changes when the number of states is 1 related issue #90113 * coverage * Apply suggestions from code review --- .../components/recorder/history/modern.py | 46 +++++++++++++++---- tests/components/recorder/test_history.py | 36 +++++++++++++++ 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 50e6102703..22bfdc3ee9 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -406,16 +406,38 @@ def _get_last_state_changes_stmt( stmt, join_attributes = _lambda_stmt_and_join_attributes( False, include_last_changed=False ) - stmt += lambda q: q.where( - States.state_id - == ( - select(States.state_id) - .filter(States.metadata_id == metadata_id) - .order_by(States.last_updated_ts.desc()) - .limit(number_of_states) - .subquery() - ).c.state_id - ) + if number_of_states == 1: + stmt += lambda q: q.join( + ( + lastest_state_for_metadata_id := ( + select( + States.metadata_id.label("max_metadata_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter(States.metadata_id == metadata_id) + .group_by(States.metadata_id) + .subquery() + ) + ), + and_( + States.metadata_id == lastest_state_for_metadata_id.c.max_metadata_id, + States.last_updated_ts + == lastest_state_for_metadata_id.c.max_last_updated, + ), + ) + else: + stmt += lambda q: q.where( + States.state_id + == ( + select(States.state_id) + .filter(States.metadata_id == metadata_id) + .order_by(States.last_updated_ts.desc()) + .limit(number_of_states) + .subquery() + ).c.state_id + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id @@ -432,6 +454,10 @@ def get_last_state_changes( entity_id_lower = entity_id.lower() entity_ids = [entity_id_lower] + # Calling this function with number_of_states > 1 can cause instability + # because it has to scan the table to find the last number_of_states states + # because the metadata_id_last_updated_ts index is in ascending order. + with session_scope(hass=hass, read_only=True) as session: instance = recorder.get_instance(hass) if not ( diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index e39cb1945f..e3aed8a398 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -382,6 +382,42 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> assert_multiple_states_equal_without_context(states, hist[entity_id]) +def test_get_last_state_change(hass_recorder: Callable[..., HomeAssistant]) -> None: + """Test getting the last state change for an entity.""" + hass = hass_recorder() + entity_id = "sensor.test" + + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state) + wait_recording_done(hass) + return hass.states.get(entity_id) + + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) + point2 = point + timedelta(minutes=1, seconds=1) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("1") + + states = [] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + set_state("2") + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 + ): + states.append(set_state("3")) + + hist = history.get_last_state_changes(hass, 1, entity_id) + + assert_multiple_states_equal_without_context(states, hist[entity_id]) + + def test_ensure_state_can_be_copied( hass_recorder: Callable[..., HomeAssistant] ) -> None: From 0bb0b4bfc50d0d1395e8b0c5d95317f8d2f28fa0 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Fri, 24 Mar 2023 14:52:50 +0100 Subject: [PATCH 0112/1165] Add livisi window sensor (WDS) (#90220) * Added support for livisi window sensor * Add const strings * added postpix for device_id * Remove unnecessary import * Fix imports * Fix lint errors, remove redundant device class property * Format code * Update .coveragerc * Finish basic window door sensor support * currently, only one binary sensor (wds) is supported * Remove unused imports * Fix isort issue * Simplify code as suggested in PR * rename get_device_response to get_device_state * fix ruff issue * Be more defensive in interpreting what we get from aiolivisi * Simplify coordinator * remove window sensor specific code (isOpen) * parameter order, type hinta * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: Tecotix <78791840+Tecotix@users.noreply.github.com> Co-authored-by: Erik Montnemery Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 1 + homeassistant/components/livisi/__init__.py | 2 +- .../components/livisi/binary_sensor.py | 110 ++++++++++++++++++ homeassistant/components/livisi/const.py | 2 + .../components/livisi/coordinator.py | 3 + 5 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/livisi/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index e59c60ddcc..24f324313a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -642,6 +642,7 @@ omit = homeassistant/components/linux_battery/sensor.py homeassistant/components/lirc/* homeassistant/components/livisi/__init__.py + homeassistant/components/livisi/binary_sensor.py homeassistant/components/livisi/climate.py homeassistant/components/livisi/coordinator.py homeassistant/components/livisi/entity.py diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index b8d8fdbfb0..b0387c6dcc 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import DOMAIN from .coordinator import LivisiDataUpdateCoordinator -PLATFORMS: Final = [Platform.CLIMATE, Platform.SWITCH] +PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SWITCH] async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/livisi/binary_sensor.py b/homeassistant/components/livisi/binary_sensor.py new file mode 100644 index 0000000000..42170bbeb4 --- /dev/null +++ b/homeassistant/components/livisi/binary_sensor.py @@ -0,0 +1,110 @@ +"""Code to handle a Livisi Binary Sensor.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +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 DOMAIN, LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE +from .coordinator import LivisiDataUpdateCoordinator +from .entity import LivisiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary_sensor device.""" + coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + known_devices = set() + + @callback + def handle_coordinator_update() -> None: + """Add Window Sensor.""" + shc_devices: list[dict[str, Any]] = coordinator.data + entities: list[BinarySensorEntity] = [] + for device in shc_devices: + if device["id"] not in known_devices and device["type"] == WDS_DEVICE_TYPE: + livisi_binary: BinarySensorEntity = LivisiWindowDoorSensor( + config_entry, coordinator, device + ) + LOGGER.debug("Include device type: %s", device["type"]) + coordinator.devices.add(device["id"]) + known_devices.add(device["id"]) + entities.append(livisi_binary) + async_add_entities(entities) + + config_entry.async_on_unload( + coordinator.async_add_listener(handle_coordinator_update) + ) + + +class LivisiBinarySensor(LivisiEntity, BinarySensorEntity): + """Represents a Livisi Binary Sensor.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + capability_name: str, + ) -> None: + """Initialize the Livisi sensor.""" + super().__init__(config_entry, coordinator, device) + self._capability_id = self.capabilities[capability_name] + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._capability_id}", + self.update_states, + ) + ) + + @callback + def update_states(self, state: bool) -> None: + """Update the state of the device.""" + self._attr_is_on = state + self.async_write_ha_state() + + +class LivisiWindowDoorSensor(LivisiBinarySensor): + """Represents a Livisi Window/Door Sensor as a Binary Sensor Entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + ) -> None: + """Initialize the Livisi window/door sensor.""" + super().__init__(config_entry, coordinator, device, "WindowDoorSensor") + + self._attr_device_class = ( + BinarySensorDeviceClass.DOOR + if (device.get("tags", {}).get("typeCategory") == "TCDoorId") + else BinarySensorDeviceClass.WINDOW + ) + + async def async_added_to_hass(self) -> None: + """Get current state.""" + await super().async_added_to_hass() + response = await self.coordinator.async_get_device_state( + self._capability_id, "isOpen" + ) + if response is None: + self._attr_available = False + else: + self._attr_is_on = response diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index 98e0b7816c..f6435298f1 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -16,6 +16,8 @@ LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change" SWITCH_DEVICE_TYPES: Final = ["ISS", "ISS2", "PSS", "PSSO"] VRCC_DEVICE_TYPE: Final = "VRCC" +WDS_DEVICE_TYPE: Final = "WDS" + MAX_TEMPERATURE: Final = 30.0 MIN_TEMPERATURE: Final = 6.0 diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 58124dfa04..f745a66e82 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -115,6 +115,9 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): self._async_dispatcher_send( LIVISI_REACHABILITY_CHANGE, event_data.source, event_data.isReachable ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.isOpen + ) async def on_close(self) -> None: """Define a handler to fire when the websocket is closed.""" From 72b09bfee7dd54d28db4f6eada3153a9c58b3966 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 09:37:43 -1000 Subject: [PATCH 0113/1165] Subclass aiohttp requests to use json helper (#90214) * Subclass aiohttp requests to use json helper * Subclass aiohttp requests to use json helper * remove unneeded * revert for new pr * override loads is never used so drop it * override loads is never used so drop it --- homeassistant/components/http/__init__.py | 44 +++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 3106eea05f..2d306ba5ee 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -1,6 +1,7 @@ """Support to serve the Home Assistant API as WSGI application.""" from __future__ import annotations +import asyncio import datetime from ipaddress import IPv4Network, IPv6Network, ip_network import logging @@ -10,9 +11,13 @@ from tempfile import NamedTemporaryFile from typing import Any, Final, TypedDict, cast from aiohttp import web -from aiohttp.typedefs import StrOrURL +from aiohttp.abc import AbstractStreamWriter +from aiohttp.http_parser import RawRequestMessage +from aiohttp.streams import StreamReader +from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection from aiohttp.web_log import AccessLogger +from aiohttp.web_protocol import RequestHandler from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -31,6 +36,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.setup import async_start_setup, async_when_setup_or_start from homeassistant.util import ssl as ssl_util +from homeassistant.util.json import json_loads from .auth import async_setup_auth from .ban import setup_bans @@ -240,6 +246,40 @@ class HomeAssistantAccessLogger(AccessLogger): super().log(request, response, time) +class HomeAssistantRequest(web.Request): + """Home Assistant request object.""" + + async def json(self, *, loads: JSONDecoder = json_loads) -> Any: + """Return body as JSON.""" + # json_loads is a wrapper around orjson.loads that handles + # bytes and str. We can pass the bytes directly to json_loads. + return json_loads(await self.read()) + + +class HomeAssistantApplication(web.Application): + """Home Assistant application.""" + + def _make_request( + self, + message: RawRequestMessage, + payload: StreamReader, + protocol: RequestHandler, + writer: AbstractStreamWriter, + task: asyncio.Task[None], + _cls: type[web.Request] = HomeAssistantRequest, + ) -> web.Request: + """Create request instance.""" + return _cls( + message, + payload, + protocol, + writer, + task, + loop=self._loop, + client_max_size=self._client_max_size, + ) + + class HomeAssistantHTTP: """HTTP server for Home Assistant.""" @@ -255,7 +295,7 @@ class HomeAssistantHTTP: ssl_profile: str, ) -> None: """Initialize the HTTP Home Assistant server.""" - self.app = web.Application( + self.app = HomeAssistantApplication( middlewares=[], client_max_size=MAX_CLIENT_SIZE, handler_args={ From 8e07b716444431a78a9ec9a18cc30c2c404440ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 09:38:08 -1000 Subject: [PATCH 0114/1165] Use the json load helper in a few more incoming web requests (#90194) * Use the json load helper in a few more incoming web requests * drop hassio change as there is no coverage there * Remove everything except emulated_hue since its has its own site/web --- homeassistant/components/emulated_hue/hue_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 41c25943a7..f779f5d8e9 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -64,6 +64,7 @@ from homeassistant.const import ( ) from homeassistant.core import State from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.util.json import json_loads from homeassistant.util.network import is_local from .config import Config @@ -138,7 +139,7 @@ class HueUsernameView(HomeAssistantView): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) try: - data = await request.json() + data = await request.json(loads=json_loads) except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) From e17cefd61cbd234e7dab8b94d4847858b29c0e40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 10:24:02 -1000 Subject: [PATCH 0115/1165] Clear recorder startup tasks from memory after processing (#90240) Co-authored-by: Paulus Schoutsen --- homeassistant/components/recorder/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 8e522a2bbd..3b92698c83 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -779,6 +779,10 @@ class Recorder(threading.Thread): for task in startup_tasks: self._guarded_process_one_task_or_recover(task) + # Clear startup tasks since this thread runs forever + # and we don't want to hold them in memory + del startup_tasks + self.stop_requested = False while not self.stop_requested: self._guarded_process_one_task_or_recover(queue_.get()) From 7f1fff12effa5d35c32c950bde1c59dfe572f5e8 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 25 Mar 2023 00:27:16 +0100 Subject: [PATCH 0116/1165] Bump aiounifi to v45 (#90250) * Bump aiounifi to v45 * Replace local TypeVar with library TypeVar --- .../components/unifi/device_tracker.py | 10 ++++---- homeassistant/components/unifi/entity.py | 23 ++++++++----------- homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/sensor.py | 14 +++++------ homeassistant/components/unifi/switch.py | 14 +++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 31 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index f891416c63..f31176afe3 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -12,6 +12,7 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.devices import Devices +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey @@ -26,7 +27,6 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, UnifiEntity, UnifiEntityDescription, @@ -136,7 +136,7 @@ def async_device_heartbeat_timedelta_fn( @dataclass -class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): """Device tracker local functions.""" heartbeat_timedelta_fn: Callable[[UniFiController, str], timedelta] @@ -147,8 +147,8 @@ class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, DataT]): @dataclass class UnifiTrackerEntityDescription( - UnifiEntityDescription[HandlerT, DataT], - UnifiEntityTrackerDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiEntityTrackerDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi device tracker entity.""" @@ -211,7 +211,7 @@ async def async_setup_entry( ) -class UnifiScannerEntity(UnifiEntity[HandlerT, DataT], ScannerEntity): +class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): """Representation of a UniFi scanner.""" entity_description: UnifiTrackerEntityDescription diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 5d763ecfe8..18a132be6a 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -13,12 +13,8 @@ from aiounifi.interfaces.api_handlers import ( ItemEvent, UnsubscribeType, ) -from aiounifi.interfaces.outlets import Outlets -from aiounifi.interfaces.ports import Ports -from aiounifi.models.api import APIItem +from aiounifi.models.api import ApiItemT from aiounifi.models.event import Event, EventKey -from aiounifi.models.outlet import Outlet -from aiounifi.models.port import Port from homeassistant.core import callback from homeassistant.helpers import entity_registry as er @@ -31,8 +27,7 @@ from .const import ATTR_MANUFACTURER if TYPE_CHECKING: from .controller import UniFiController -DataT = TypeVar("DataT", bound=APIItem | Outlet | Port) -HandlerT = TypeVar("HandlerT", bound=APIHandler | Outlets | Ports) +HandlerT = TypeVar("HandlerT", bound=APIHandler) SubscriptionT = Callable[[CallbackType, ItemEvent], UnsubscribeType] @@ -64,7 +59,7 @@ def async_device_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Device @dataclass -class UnifiDescription(Generic[HandlerT, DataT]): +class UnifiDescription(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" allowed_fn: Callable[[UniFiController, str], bool] @@ -73,21 +68,21 @@ class UnifiDescription(Generic[HandlerT, DataT]): device_info_fn: Callable[[aiounifi.Controller, str], DeviceInfo | None] event_is_on: tuple[EventKey, ...] | None event_to_subscribe: tuple[EventKey, ...] | None - name_fn: Callable[[DataT], str | None] - object_fn: Callable[[aiounifi.Controller, str], DataT] + name_fn: Callable[[ApiItemT], str | None] + object_fn: Callable[[aiounifi.Controller, str], ApiItemT] supported_fn: Callable[[UniFiController, str], bool | None] unique_id_fn: Callable[[UniFiController, str], str] @dataclass -class UnifiEntityDescription(EntityDescription, UnifiDescription[HandlerT, DataT]): +class UnifiEntityDescription(EntityDescription, UnifiDescription[HandlerT, ApiItemT]): """UniFi Entity Description.""" -class UnifiEntity(Entity, Generic[HandlerT, DataT]): +class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): """Representation of a UniFi entity.""" - entity_description: UnifiEntityDescription[HandlerT, DataT] + entity_description: UnifiEntityDescription[HandlerT, ApiItemT] _attr_should_poll = False _attr_unique_id: str @@ -96,7 +91,7 @@ class UnifiEntity(Entity, Generic[HandlerT, DataT]): self, obj_id: str, controller: UniFiController, - description: UnifiEntityDescription[HandlerT, DataT], + description: UnifiEntityDescription[HandlerT, ApiItemT], ) -> None: """Set up UniFi switch entity.""" self._obj_id = obj_id diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 92f879c104..7fde8a2ad7 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==44"], + "requirements": ["aiounifi==45"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 420fc3803c..3682fa0bf6 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -14,6 +14,7 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.ports import Ports +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.port import Port @@ -33,7 +34,6 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, UnifiEntity, UnifiEntityDescription, @@ -80,17 +80,17 @@ def async_client_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Device @dataclass -class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" - value_fn: Callable[[UniFiController, DataT], datetime | float | str | None] + value_fn: Callable[[UniFiController, ApiItemT], datetime | float | str | None] @dataclass class UnifiSensorEntityDescription( SensorEntityDescription, - UnifiEntityDescription[HandlerT, DataT], - UnifiSensorEntityDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiSensorEntityDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi sensor entity.""" @@ -182,10 +182,10 @@ async def async_setup_entry( ) -class UnifiSensorEntity(UnifiEntity[HandlerT, DataT], SensorEntity): +class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): """Base representation of a UniFi sensor.""" - entity_description: UnifiSensorEntityDescription[HandlerT, DataT] + entity_description: UnifiSensorEntityDescription[HandlerT, ApiItemT] @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index bf724cec1f..bd0166516d 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -17,6 +17,7 @@ from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.ports import Ports +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client, ClientBlockRequest from aiounifi.models.device import ( DeviceSetOutletRelayRequest, @@ -47,7 +48,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, SubscriptionT, UnifiEntity, @@ -136,18 +136,18 @@ async def async_poe_port_control_fn( @dataclass -class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" control_fn: Callable[[aiounifi.Controller, str, bool], Coroutine[Any, Any, None]] - is_on_fn: Callable[[UniFiController, DataT], bool] + is_on_fn: Callable[[UniFiController, ApiItemT], bool] @dataclass class UnifiSwitchEntityDescription( SwitchEntityDescription, - UnifiEntityDescription[HandlerT, DataT], - UnifiSwitchEntityDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiSwitchEntityDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi switch entity.""" @@ -255,10 +255,10 @@ async def async_setup_entry( ) -class UnifiSwitchEntity(UnifiEntity[HandlerT, DataT], SwitchEntity): +class UnifiSwitchEntity(UnifiEntity[HandlerT, ApiItemT], SwitchEntity): """Base representation of a UniFi switch.""" - entity_description: UnifiSwitchEntityDescription[HandlerT, DataT] + entity_description: UnifiSwitchEntityDescription[HandlerT, ApiItemT] only_event_for_state_change = False @callback diff --git a/requirements_all.txt b/requirements_all.txt index 7d1670833f..e4b5a9999d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -291,7 +291,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==44 +aiounifi==45 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bb56a1be4..2e31358bca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -272,7 +272,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==44 +aiounifi==45 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 5f3868b1419e5286777da7d5483e1a3266d98486 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Mar 2023 03:34:01 +0100 Subject: [PATCH 0117/1165] Add missing type hints to tests (#90218) * Add type hints to tests * Revert gree as handled in #90222 --- tests/components/calendar/test_trigger.py | 2 +- .../local_calendar/test_diagnostics.py | 7 +++---- tests/components/zha/test_api.py | 19 +++++++++++-------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 9e15a1996d..e210bd7ac3 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -668,7 +668,7 @@ async def test_trigger_timestamp_window_edge( async def test_event_start_trigger_dst( - hass: HomeAssistant, calls, fake_schedule, freezer + hass: HomeAssistant, calls, fake_schedule, freezer: FrozenDateTimeFactory ) -> None: """Test a calendar event trigger happening at the start of daylight savings time.""" tzinfo = zoneinfo.ZoneInfo("America/Los_Angeles") diff --git a/tests/components/local_calendar/test_diagnostics.py b/tests/components/local_calendar/test_diagnostics.py index 561f7588a5..9a1da25d77 100644 --- a/tests/components/local_calendar/test_diagnostics.py +++ b/tests/components/local_calendar/test_diagnostics.py @@ -1,5 +1,4 @@ """Tests for diagnostics platform of local calendar.""" - from aiohttp.test_utils import TestClient from freezegun import freeze_time import pytest @@ -9,11 +8,11 @@ from homeassistant.auth.models import Credentials from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY, Client, ClientFixture +from .conftest import TEST_ENTITY, Client from tests.common import CLIENT_ID, MockConfigEntry, MockUser from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator async def generate_new_hass_access_token( @@ -82,7 +81,7 @@ async def test_api_date_time_event( hass_admin_user: MockUser, hass_admin_credential: Credentials, config_entry: MockConfigEntry, - hass_ws_client: ClientFixture, + hass_ws_client: WebSocketGenerator, aiohttp_client: ClientSessionGenerator, socket_enabled: None, snapshot: SnapshotAssertion, diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 0d03b62bf8..c607909980 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -7,6 +7,7 @@ import zigpy.state from homeassistant.components import zha from homeassistant.components.zha import api from homeassistant.components.zha.core.const import RadioType +from homeassistant.core import HomeAssistant @pytest.fixture(autouse=True) @@ -16,7 +17,9 @@ def required_platform_only(): yield -async def test_async_get_network_settings_active(hass, setup_zha): +async def test_async_get_network_settings_active( + hass: HomeAssistant, setup_zha +) -> None: """Test reading settings with an active ZHA installation.""" await setup_zha() @@ -25,8 +28,8 @@ async def test_async_get_network_settings_active(hass, setup_zha): async def test_async_get_network_settings_inactive( - hass, setup_zha, zigpy_app_controller -): + hass: HomeAssistant, setup_zha, zigpy_app_controller +) -> None: """Test reading settings with an inactive ZHA installation.""" await setup_zha() @@ -48,8 +51,8 @@ async def test_async_get_network_settings_inactive( async def test_async_get_network_settings_missing( - hass, setup_zha, zigpy_app_controller -): + hass: HomeAssistant, setup_zha, zigpy_app_controller +) -> None: """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() @@ -69,13 +72,13 @@ async def test_async_get_network_settings_missing( assert settings is None -async def test_async_get_network_settings_failure(hass): +async def test_async_get_network_settings_failure(hass: HomeAssistant) -> None: """Test reading settings with no ZHA config entries and no database.""" with pytest.raises(ValueError): await api.async_get_network_settings(hass) -async def test_async_get_radio_type_active(hass, setup_zha): +async def test_async_get_radio_type_active(hass: HomeAssistant, setup_zha) -> None: """Test reading the radio type with an active ZHA installation.""" await setup_zha() @@ -83,7 +86,7 @@ async def test_async_get_radio_type_active(hass, setup_zha): assert radio_type == RadioType.ezsp -async def test_async_get_radio_path_active(hass, setup_zha): +async def test_async_get_radio_path_active(hass: HomeAssistant, setup_zha) -> None: """Test reading the radio path with an active ZHA installation.""" await setup_zha() From f56bf134d280fe9085e7fb56c5f419101937e73d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Mar 2023 05:49:12 +0100 Subject: [PATCH 0118/1165] Improve browse_media type hints in media player (#90060) * Improve browse_media type hints in media player * Adjust components * Adjust base entity --- homeassistant/components/braviatv/media_player.py | 2 +- homeassistant/components/dlna_dmr/media_player.py | 2 +- homeassistant/components/esphome/media_player.py | 4 +++- homeassistant/components/forked_daapd/media_player.py | 2 +- homeassistant/components/frontier_silicon/media_player.py | 4 +++- homeassistant/components/fully_kiosk/media_player.py | 2 +- homeassistant/components/gstreamer/media_player.py | 4 +++- homeassistant/components/heos/media_player.py | 4 +++- homeassistant/components/jellyfin/media_player.py | 4 +++- homeassistant/components/kodi/media_player.py | 4 +++- homeassistant/components/media_player/__init__.py | 2 +- homeassistant/components/mpd/media_player.py | 4 +++- homeassistant/components/openhome/media_player.py | 4 +++- homeassistant/components/panasonic_viera/media_player.py | 4 +++- homeassistant/components/philips_js/media_player.py | 4 +++- homeassistant/components/plex/media_player.py | 4 +++- homeassistant/components/roku/media_player.py | 2 +- homeassistant/components/roon/media_player.py | 4 +++- homeassistant/components/slimproto/media_player.py | 4 +++- homeassistant/components/sonos/media_player.py | 4 +++- homeassistant/components/soundtouch/media_player.py | 4 +++- homeassistant/components/spotify/media_player.py | 4 +++- homeassistant/components/universal/media_player.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 2 +- 24 files changed, 56 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index 917bd1d541..c09df32aea 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -136,7 +136,7 @@ class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Browse apps and channels.""" diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index a866b911f3..eddb2633be 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -767,7 +767,7 @@ class DlnaDmrEntity(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper. diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index f8566e863c..673a90580e 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -115,7 +115,9 @@ class EsphomeMediaPlayer( ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index ca7e0cce27..d5f40c37b5 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -836,7 +836,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index b05ba272a1..7f73823239 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -328,7 +328,9 @@ class AFSAPIDevice(MediaPlayerEntity): await self.fs_device.set_eq_preset(mode) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Browse media library and preset stations.""" if not media_content_id: diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index 0fcd8c3543..8c73d47dd7 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -72,7 +72,7 @@ class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the WebSocket media browsing helper.""" diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index 04e91e4317..cb221d4941 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -166,7 +166,9 @@ class GstreamerDevice(MediaPlayerEntity): return self._album async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 3147c1e166..9ad33caf07 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -427,7 +427,9 @@ class HeosMediaPlayer(MediaPlayerEntity): return self._player.volume / 100 async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 32ca1d59d7..2025e1a2a6 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -283,7 +283,9 @@ class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): self.coordinator.api_client.jellyfin.remote_unmute(self.session_id) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance. diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 029eedb242..63875236be 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -884,7 +884,9 @@ class KodiEntity(MediaPlayerEntity): return sorted(out, key=lambda out: out[1], reverse=True) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" is_internal = is_internal_request(self.hass) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 3938cc64f7..8810ea165d 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1037,7 +1037,7 @@ class MediaPlayerEntity(Entity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance. diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 7395777320..457f905824 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -509,7 +509,9 @@ class MpdDevice(MediaPlayerEntity): await self._client.seekcur(position) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 68357c862c..b625d9976d 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -347,7 +347,9 @@ class OpenhomeDevice(MediaPlayerEntity): await self._device.set_mute(mute) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index 8b676f37c2..5e2ed77233 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -203,7 +203,9 @@ class PanasonicVieraTVEntity(MediaPlayerEntity): await self._remote.async_play_media(media_type, media_id) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media(self.hass, media_content_id) diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index 89cb29f0a0..e8250dc8eb 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -391,7 +391,9 @@ class PhilipsTVMediaPlayer( ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" if not self._tv.on: diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index c1a3ac5bd3..be57267960 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -541,7 +541,9 @@ class PlexMediaPlayer(MediaPlayerEntity): ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" is_internal = is_internal_request(self.hass) diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index b0191f605d..cf6563519f 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -278,7 +278,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 307765da5c..3bcafe4ba9 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -498,7 +498,9 @@ class RoonDevice(MediaPlayerEntity): ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await self.hass.async_add_executor_job( diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 597ed50f42..641d3b8ae4 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -195,7 +195,9 @@ class SlimProtoPlayer(MediaPlayerEntity): await self.player.play_url(media_id, mime_type=to_send_media_type) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index fbd74e5774..1ef86429cb 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -712,7 +712,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): return (None, None) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_browser.async_browse_media( diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 111a13c2c9..7211843133 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -398,7 +398,9 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): return attributes async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media(self.hass, media_content_id) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 7c583eb533..b63a951381 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -398,7 +398,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): self._playlist = self.data.client.playlist(current["context"]["uri"]) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 21d741d345..fd73ad33e1 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -630,7 +630,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance.""" diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 6394f84222..f25b8db84a 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2003,7 +2003,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="async_browse_media", arg_types={ - 1: "str | None", + 1: "MediaType | str | None", 2: "str | None", }, return_type="BrowseMedia", From 970036b32873f31c76ab5979aebdc81181af83f0 Mon Sep 17 00:00:00 2001 From: Jeef Date: Sat, 25 Mar 2023 02:15:46 -0600 Subject: [PATCH 0119/1165] Refactor Gree switch to use EntityDescription (#90143) * Post-rebase * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * feat: Tests passing! * Removing custom attributes as no longer needed * removed extraneous class * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/gree/test_switch.py Co-authored-by: solazs * Update tests/components/gree/test_switch.py Co-authored-by: solazs --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: solazs --- homeassistant/components/gree/switch.py | 193 ++++++------------------ 1 file changed, 42 insertions(+), 151 deletions(-) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index ffef6b08a9..0ac7406713 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -1,9 +1,13 @@ """Support for interface with a Gree climate systems.""" from __future__ import annotations -from typing import Any +from typing import Any, cast -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -12,6 +16,29 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN from .entity import GreeEntity +GREE_SWITCHES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + icon="mdi:lightbulb", + name="Panel Light", + key="light", + ), + SwitchEntityDescription( + name="Quiet", + key="quiet", + ), + SwitchEntityDescription( + name="Fresh Air", + key="fresh_air", + ), + SwitchEntityDescription(name="XFan", key="xfan"), + SwitchEntityDescription( + icon="mdi:pine-tree", + name="Health mode", + key="anion", + entity_registry_enabled_default=False, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -23,14 +50,10 @@ async def async_setup_entry( @callback def init_device(coordinator): """Register the device.""" + async_add_entities( - [ - GreePanelLightSwitchEntity(coordinator), - GreeHealthModeSwitchEntity(coordinator), - GreeQuietModeSwitchEntity(coordinator), - GreeFreshAirSwitchEntity(coordinator), - GreeXFanSwitchEntity(coordinator), - ] + GreeSwitch(coordinator=coordinator, description=description) + for description in GREE_SWITCHES ) for coordinator in hass.data[DOMAIN][COORDINATORS]: @@ -41,162 +64,30 @@ async def async_setup_entry( ) -class GreePanelLightSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the front panel light on the device.""" +class GreeSwitch(GreeEntity, SwitchEntity): + """Generic Gree switch entity.""" - def __init__(self, coordinator): + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, coordinator, description: SwitchEntityDescription) -> None: """Initialize the Gree device.""" - super().__init__(coordinator, "Panel Light") + self.entity_description = description - @property - def icon(self) -> str | None: - """Return the icon for the device.""" - return "mdi:lightbulb" - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the light is turned on.""" - return self.coordinator.device.light - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.light = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.light = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeHealthModeSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the health mode on the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "Health mode") - self._attr_entity_registry_enabled_default = False - - @property - def icon(self) -> str | None: - """Return the icon for the device.""" - return "mdi:pine-tree" - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the health mode is turned on.""" - return self.coordinator.device.anion - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.anion = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.anion = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeQuietModeSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the quiet mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "Quiet") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH + super().__init__(coordinator, cast(str, description.name)) @property def is_on(self) -> bool: """Return if the state is turned on.""" - return self.coordinator.device.quiet + return getattr(self.coordinator.device, self.entity_description.key) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - self.coordinator.device.quiet = True + setattr(self.coordinator.device, self.entity_description.key, True) await self.coordinator.push_state_update() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self.coordinator.device.quiet = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeFreshAirSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the fresh air mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "Fresh Air") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the state is turned on.""" - return self.coordinator.device.fresh_air - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.fresh_air = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.fresh_air = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeXFanSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the extra fan mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "XFan") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the state is turned on.""" - return self.coordinator.device.xfan - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.xfan = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.xfan = False + setattr(self.coordinator.device, self.entity_description.key, False) await self.coordinator.push_state_update() self.async_write_ha_state() From 52a94dd2ac50b9035555e69100db33e968e9365d Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 24 Mar 2023 21:36:23 -1100 Subject: [PATCH 0120/1165] Check for empty lists in KNX address configuration (#90249) --- homeassistant/components/knx/schema.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index c6206e883e..a505714c0d 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -101,7 +101,11 @@ def ga_validator(value: Any) -> str | int: ) -ga_list_validator = vol.All(cv.ensure_list, [ga_validator]) +ga_list_validator = vol.All( + cv.ensure_list, + [ga_validator], + vol.IsTrue("value must be a group address or a list containing group addresses"), +) ia_validator = vol.Any( vol.All(str, str.strip, cv.matches_regex(IndividualAddress.ADDRESS_RE.pattern)), From 02ef7d445d8a723fe57712f37ca9c27595c5d1a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 04:11:14 -1000 Subject: [PATCH 0121/1165] Allow passing an optional name to async_track_time_interval (#90244) * Allow passing an optional name to async_track_time_interval This is the same idea as passing a name to asyncio.create_task which makes it easier to track down bugs * more * short * still cannot find it * add a few more * test --- .../components/analytics/__init__.py | 4 +++- homeassistant/components/august/subscriber.py | 2 +- .../components/bluetooth/base_scanner.py | 10 +++++++-- homeassistant/components/bluetooth/manager.py | 1 + homeassistant/components/bond/entity.py | 5 ++++- homeassistant/components/camera/__init__.py | 4 +++- .../components/device_tracker/legacy.py | 7 +++++- homeassistant/components/dhcp/__init__.py | 2 +- .../homekit_controller/connection.py | 6 ++++- homeassistant/components/recorder/core.py | 15 ++++++++++--- homeassistant/components/ssdp/__init__.py | 2 +- homeassistant/helpers/entity_platform.py | 1 + homeassistant/helpers/event.py | 10 ++++++--- homeassistant/helpers/restore_state.py | 5 ++++- tests/helpers/test_event.py | 22 +++++++++++++++++++ 15 files changed, 79 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index ad53fb0311..7bf55480eb 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -27,7 +27,9 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: async_call_later(hass, 900, analytics.send_analytics) # Send every day - async_track_time_interval(hass, analytics.send_analytics, INTERVAL) + async_track_time_interval( + hass, analytics.send_analytics, INTERVAL, "analytics daily" + ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 5223b8b4a3..e0982fe9fb 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -38,7 +38,7 @@ class AugustSubscriberMixin: def _async_setup_listeners(self): """Create interval and stop listeners.""" self._unsub_interval = async_track_time_interval( - self._hass, self._async_refresh, self._update_interval + self._hass, self._async_refresh, self._update_interval, "august refresh" ) @callback diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 1c16639d61..f1ffd6ecf5 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -98,7 +98,10 @@ class BaseHaScanner(ABC): self._start_time = self._last_detection = MONOTONIC_TIME() if not self._cancel_watchdog: self._cancel_watchdog = async_track_time_interval( - self.hass, self._async_scanner_watchdog, SCANNER_WATCHDOG_INTERVAL + self.hass, + self._async_scanner_watchdog, + SCANNER_WATCHDOG_INTERVAL, + f"{self.name} Bluetooth scanner watchdog", ) @hass_callback @@ -224,7 +227,10 @@ class BaseHaRemoteScanner(BaseHaScanner): self._async_expire_devices(dt_util.utcnow()) cancel_track = async_track_time_interval( - self.hass, self._async_expire_devices, timedelta(seconds=30) + self.hass, + self._async_expire_devices, + timedelta(seconds=30), + f"{self.name} Bluetooth scanner device expire", ) cancel_stop = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_save_history diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index bc21051656..7932520b45 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -276,6 +276,7 @@ class BluetoothManager: self.hass, self._async_check_unavailable, timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), + "Bluetooth manager unavailable tracking", ) @hass_callback diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 8c9fef6bd7..d00646d6ff 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -174,7 +174,10 @@ class BondEntity(Entity): self._bpup_subs.subscribe(self._device_id, self._async_bpup_callback) self.async_on_remove( async_track_time_interval( - self.hass, self._async_update_if_bpup_not_alive, _FALLBACK_SCAN_INTERVAL + self.hass, + self._async_update_if_bpup_not_alive, + _FALLBACK_SCAN_INTERVAL, + f"Bond {self.entity_id} fallback polling", ) ) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index e368779e94..673009268c 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -379,7 +379,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity.async_update_token() entity.async_write_ha_state() - unsub = async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) + unsub = async_track_time_interval( + hass, update_tokens, TOKEN_CHANGE_INTERVAL, "Camera update tokens" + ) @callback def unsub_track_time_interval(_event: Event) -> None: diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index bc792ee892..af70939ee6 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -423,7 +423,12 @@ def async_setup_scanner_platform( hass.async_create_task(async_see_device(**kwargs)) - async_track_time_interval(hass, async_device_tracker_scan, interval) + async_track_time_interval( + hass, + async_device_tracker_scan, + interval, + f"device_tracker {platform} legacy scan", + ) hass.async_create_task(async_device_tracker_scan(None)) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 74dd1f66ab..ea5a5fb79a 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -260,7 +260,7 @@ class NetworkWatcher(WatcherBase): """Start scanning for new devices on the network.""" self._discover_hosts = DiscoverHosts() self._unsub = async_track_time_interval( - self.hass, self.async_start_discover, SCAN_INTERVAL + self.hass, self.async_start_discover, SCAN_INTERVAL, "DHCP network watcher" ) self.async_start_discover() diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 4814e7833c..9e56c7c24e 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -272,6 +272,7 @@ class HKDevice: self.hass, self.async_update_available_state, timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL), + f"HomeKit Controller {self.unique_id} BLE availability check poll", ) ) # BLE devices always get an RSSI sensor as well @@ -286,7 +287,10 @@ class HKDevice: # in the log about concurrent polling. self.config_entry.async_on_unload( async_track_time_interval( - self.hass, self.async_request_update, self.pairing.poll_interval + self.hass, + self.async_request_update, + self.pairing.poll_interval, + f"HomeKit Controller {self.unique_id} availability check poll", ) ) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 3b92698c83..fbc929b17d 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -296,7 +296,10 @@ class Recorder(threading.Thread): run_immediately=True, ) self._queue_watcher = async_track_time_interval( - self.hass, self._async_check_queue, timedelta(minutes=10) + self.hass, + self._async_check_queue, + timedelta(minutes=10), + "Recorder queue watcher", ) @callback @@ -596,13 +599,19 @@ class Recorder(threading.Thread): # to prevent errors from unexpected disconnects if self.dialect_name != SupportedDialect.SQLITE: self._keep_alive_listener = async_track_time_interval( - self.hass, self._async_keep_alive, timedelta(seconds=KEEPALIVE_TIME) + self.hass, + self._async_keep_alive, + timedelta(seconds=KEEPALIVE_TIME), + "Recorder keep alive", ) # If the commit interval is not 0, we need to commit periodically if self.commit_interval: self._commit_listener = async_track_time_interval( - self.hass, self._async_commit, timedelta(seconds=self.commit_interval) + self.hass, + self._async_commit, + timedelta(seconds=self.commit_interval), + "Recorder commit", ) # Run nightly tasks at 4:12am diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index b7e28f2704..b7ed28885c 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -401,7 +401,7 @@ class Scanner: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) self._cancel_scan = async_track_time_interval( - self.hass, self.async_scan, SCAN_INTERVAL + self.hass, self.async_scan, SCAN_INTERVAL, "SSDP scanner" ) # Trigger the initial-scan. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 6687af6a27..7b7b809404 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -479,6 +479,7 @@ class EntityPlatform: self.hass, self._update_entity_states, self.scan_interval, + f"EntityPlatform poll {self.domain}.{self.platform_name}", ) def _entity_id_already_exists(self, entity_id: str) -> tuple[bool, bool]: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 3ac715426e..a456e12fa1 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1397,6 +1397,7 @@ def async_track_time_interval( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, None] | None], interval: timedelta, + name: str | None = None, ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" remove: CALLBACK_TYPE @@ -1419,9 +1420,12 @@ def async_track_time_interval( ) hass.async_run_hass_job(job, now) - interval_listener_job = HassJob( - interval_listener, f"track time interval listener {interval}" - ) + if name: + job_name = f"{name}: track time interval {interval}" + else: + job_name = f"track time interval {interval}" + + interval_listener_job = HassJob(interval_listener, job_name) remove = async_track_point_in_utc_time(hass, interval_listener_job, next_interval()) def remove_listener() -> None: diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 0263bd2868..e35a66ada8 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -216,7 +216,10 @@ class RestoreStateData: # Dump states periodically cancel_interval = async_track_time_interval( - self.hass, _async_dump_states, STATE_DUMP_INTERVAL + self.hass, + _async_dump_states, + STATE_DUMP_INTERVAL, + "RestoreStateData dump states", ) async def _async_dump_states_at_stop(*_: Any) -> None: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 211663babc..7e84d634ef 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3438,6 +3438,28 @@ async def test_track_time_interval(hass: HomeAssistant) -> None: assert len(specific_runs) == 2 +async def test_track_time_interval_name(hass: HomeAssistant) -> None: + """Test tracking time interval name. + + This test is to ensure that when a name is passed to async_track_time_interval, + that the name can be found in the TimerHandle when stringified. + """ + specific_runs = [] + unique_string = "xZ13" + unsub = async_track_time_interval( + hass, + callback(lambda x: specific_runs.append(x)), + timedelta(seconds=10), + unique_string, + ) + scheduled = getattr(hass.loop, "_scheduled") + assert any(handle for handle in scheduled if unique_string in str(handle)) + unsub() + + assert all(handle for handle in scheduled if unique_string not in str(handle)) + await hass.async_block_till_done() + + async def test_track_sunrise(hass: HomeAssistant) -> None: """Test track the sunrise.""" latitude = 32.87336 From 6d8eaa0beecee7d229929ccd6ec665ee24dde771 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Sat, 25 Mar 2023 17:43:49 +0100 Subject: [PATCH 0122/1165] Add location field to calendar create_event service supported by Google Calendar and Local Calendar (#90098) * Update __init__.py * Update __init__.py * Update __init__.py * Update calendar.py * Update calendar.py * Update services.yaml * Update services.yaml * Update calendar.py * Update calendar.py * Update __init__.py * Update services.yaml * Update services.yaml * Update test_calendar.py * Update test_init.py * Update test_init.py * Update test_init.py * Update test_init.py * Update __init__.py * Update const.py * Address changes to service.yaml * Address changes to service.yaml * Update test_calendar.py * Update test_calendar.py * Update test_calendar.py * Update conftest.py * Update conftest.py * Update calendar.py * Update __init__.py --- homeassistant/components/calendar/__init__.py | 3 +++ homeassistant/components/calendar/services.yaml | 6 ++++++ homeassistant/components/google/__init__.py | 3 +++ homeassistant/components/google/calendar.py | 3 +++ homeassistant/components/google/const.py | 1 + homeassistant/components/google/services.yaml | 6 ++++++ homeassistant/components/local_calendar/calendar.py | 1 + tests/components/google/test_calendar.py | 2 ++ tests/components/google/test_init.py | 5 +++++ tests/components/local_calendar/conftest.py | 4 +++- tests/components/local_calendar/test_calendar.py | 4 ++++ 11 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index d09a389ce8..9af3244656 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -42,6 +42,7 @@ from .const import ( EVENT_IN, EVENT_IN_DAYS, EVENT_IN_WEEKS, + EVENT_LOCATION, EVENT_RECURRENCE_ID, EVENT_RECURRENCE_RANGE, EVENT_RRULE, @@ -176,6 +177,7 @@ CREATE_EVENT_SCHEMA = vol.All( { vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Optional(EVENT_LOCATION): cv.string, vol.Inclusive( EVENT_START_DATE, "dates", "Start and end dates must both be specified" ): cv.date, @@ -213,6 +215,7 @@ WEBSOCKET_EVENT_SCHEMA = vol.Schema( vol.Required(EVENT_END): vol.Any(cv.date, cv.datetime), vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION): cv.string, + vol.Optional(EVENT_LOCATION): cv.string, vol.Optional(EVENT_RRULE): _validate_rrule, }, _has_same_type(EVENT_START, EVENT_END), diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index dfe278a92d..5d1a3ccf0f 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -46,3 +46,9 @@ create_event: name: In description: Days or weeks that you want to create the event in. example: '{"days": 2} or {"weeks": 2}' + location: + name: Location + description: The location of the event. + example: "Conference Room - F123, Bldg. 002" + selector: + text: diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 934b34c126..25993760d8 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -43,6 +43,7 @@ from .const import ( EVENT_IN, EVENT_IN_DAYS, EVENT_IN_WEEKS, + EVENT_LOCATION, EVENT_START_DATE, EVENT_START_DATETIME, EVENT_SUMMARY, @@ -116,6 +117,7 @@ ADD_EVENT_SERVICE_SCHEMA = vol.All( vol.Required(EVENT_CALENDAR_ID): cv.string, vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Optional(EVENT_LOCATION, default=""): cv.string, vol.Inclusive( EVENT_START_DATE, "dates", "Start and end dates must both be specified" ): cv.date, @@ -290,6 +292,7 @@ async def async_setup_add_event_service( Event( summary=call.data[EVENT_SUMMARY], description=call.data[EVENT_DESCRIPTION], + location=call.data[EVENT_LOCATION], start=start, end=end, ), diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index d20155ad90..1e1072940a 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -24,6 +24,7 @@ from homeassistant.components.calendar import ( ENTITY_ID_FORMAT, EVENT_DESCRIPTION, EVENT_END, + EVENT_LOCATION, EVENT_RRULE, EVENT_START, EVENT_SUMMARY, @@ -507,6 +508,7 @@ class GoogleCalendarEntity( "start": start, "end": end, EVENT_DESCRIPTION: kwargs.get(EVENT_DESCRIPTION), + EVENT_LOCATION: kwargs.get(EVENT_LOCATION), } ) if rrule := kwargs.get(EVENT_RRULE): @@ -603,6 +605,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> Event( summary=call.data[EVENT_SUMMARY], description=call.data[EVENT_DESCRIPTION], + location=call.data[EVENT_LOCATION], start=start, end=end, ), diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index 6a2c1974f6..add98441e3 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -38,6 +38,7 @@ EVENT_END_DATETIME = "end_date_time" EVENT_IN = "in" EVENT_IN_DAYS = "days" EVENT_IN_WEEKS = "weeks" +EVENT_LOCATION = "location" EVENT_START_DATE = "start_date" EVENT_START_DATETIME = "start_date_time" EVENT_SUMMARY = "summary" diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml index a303ad7e18..e7eeef7594 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -103,3 +103,9 @@ create_event: example: '"days": 2 or "weeks": 2' selector: object: + location: + name: Location + description: The location of the event. Optional. + example: "Conference Room - F123, Bldg. 002" + selector: + text: diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 9cb6878ca5..2905e98caa 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -196,4 +196,5 @@ def _get_calendar_event(event: Event) -> CalendarEvent: uid=event.uid, rrule=event.rrule.as_rrule_str() if event.rrule else None, recurrence_id=event.recurrence_id, + location=event.location, ) diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 8b544a828e..6d0ea7c51f 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -888,6 +888,7 @@ async def test_websocket_create( assert aioclient_mock.mock_calls[0][2] == { "summary": "Bastille Day Party", "description": None, + "location": None, "start": { "dateTime": "1997-07-14T11:00:00-06:00", "timeZone": "America/Regina", @@ -931,6 +932,7 @@ async def test_websocket_create_all_day( assert aioclient_mock.mock_calls[0][2] == { "summary": "Bastille Day Party", "description": None, + "location": None, "start": { "date": "1997-07-14", }, diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index eac3bff585..938dd2c28e 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -42,6 +42,7 @@ HassApi = Callable[[], Awaitable[dict[str, Any]]] TEST_EVENT_SUMMARY = "Test Summary" TEST_EVENT_DESCRIPTION = "Test Description" +TEST_EVENT_LOCATION = "Test Location" def assert_state(actual: State | None, expected: State | None) -> None: @@ -93,6 +94,7 @@ def add_event_call_service( **params, "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, }, target=target, blocking=True, @@ -484,6 +486,7 @@ async def test_add_event_date_in_x( assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, "start": {"date": start_date.date().isoformat()}, "end": {"date": end_date.date().isoformat()}, } @@ -524,6 +527,7 @@ async def test_add_event_date( assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, "start": {"date": today.isoformat()}, "end": {"date": end_date.isoformat()}, } @@ -564,6 +568,7 @@ async def test_add_event_date_time( assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, "start": { "dateTime": start_datetime.isoformat(timespec="seconds"), "timeZone": "America/Regina", diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py index bde9c226ba..b083bbac78 100644 --- a/tests/components/local_calendar/conftest.py +++ b/tests/components/local_calendar/conftest.py @@ -108,7 +108,9 @@ def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: def event_fields(data: dict[str, str]) -> dict[str, str]: """Filter event API response to minimum fields.""" return { - k: data[k] for k in ["summary", "start", "end", "recurrence_id"] if data.get(k) + k: data[k] + for k in ["summary", "start", "end", "recurrence_id", "location"] + if data.get(k) } diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 319a352f62..6bdb58cf65 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -873,6 +873,7 @@ async def test_create_event_service( "start_date_time": start_date_time, "end_date_time": end_date_time, "summary": "Bastille Day Party", + "location": "Test Location", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -886,6 +887,7 @@ async def test_create_event_service( "summary": "Bastille Day Party", "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + "location": "Test Location", } ] @@ -895,6 +897,7 @@ async def test_create_event_service( "summary": "Bastille Day Party", "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + "location": "Test Location", } ] @@ -909,5 +912,6 @@ async def test_create_event_service( "summary": "Bastille Day Party", "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + "location": "Test Location", } ] From 7cbe705ebb8176ed378da9c615d77e4deae46dca Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Mar 2023 18:00:15 +0100 Subject: [PATCH 0123/1165] Update vehicle to 1.0.0 (#90189) --- homeassistant/components/rdw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 2cb660921c..5ec3a6ae19 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["vehicle==0.4.0"] + "requirements": ["vehicle==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4b5a9999d..68cba63edf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2574,7 +2574,7 @@ uvcclient==0.11.0 vallox-websocket-api==3.0.0 # homeassistant.components.rdw -vehicle==0.4.0 +vehicle==1.0.0 # homeassistant.components.velbus velbus-aio==2023.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e31358bca..a3e7cd970c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1832,7 +1832,7 @@ uvcclient==0.11.0 vallox-websocket-api==3.0.0 # homeassistant.components.rdw -vehicle==0.4.0 +vehicle==1.0.0 # homeassistant.components.velbus velbus-aio==2023.2.0 From 7bceedfc95f10ffb2ff6a0f68b87908ea25a3620 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 07:05:35 -1000 Subject: [PATCH 0124/1165] Bump sqlalchemy to 2.0.7 (#90256) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 4f87c19ca7..c64c38fb7e 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.6", "fnvhash==0.1.0"] + "requirements": ["sqlalchemy==2.0.7", "fnvhash==0.1.0"] } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 7513bbd8c7..2fed7d9794 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.6"] + "requirements": ["sqlalchemy==2.0.7"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0fcae3ec80..2f0cff1319 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -42,7 +42,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.2 scapy==2.5.0 -sqlalchemy==2.0.6 +sqlalchemy==2.0.7 typing-extensions>=4.5.0,<5.0 ulid-transform==0.5.1 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 68cba63edf..cf804be500 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2398,7 +2398,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.6 +sqlalchemy==2.0.7 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3e7cd970c..b0ee47f771 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1707,7 +1707,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.6 +sqlalchemy==2.0.7 # homeassistant.components.srp_energy srpenergy==1.3.6 From cc337c4ff6dbb648714a5d2df6aae9c016296034 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sat, 25 Mar 2023 18:09:33 +0100 Subject: [PATCH 0125/1165] Add Re-Auth to bmw_connected_drive (#90251) * Add Re-Auth to bmw_connected_drive * Always store refresh token to entry * Fix tests * Typo --------- Co-authored-by: rikroe --- .../bmw_connected_drive/config_flow.py | 42 ++++++++++++--- .../bmw_connected_drive/coordinator.py | 4 +- .../bmw_connected_drive/strings.json | 3 +- .../bmw_connected_drive/test_config_flow.py | 51 ++++++++++++++++++- 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 4f05794e31..0cde37ba6b 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -1,6 +1,7 @@ """Config flow for BMW ConnectedDrive integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from bimmer_connected.api.authentication import MyBMWAuthentication @@ -55,36 +56,61 @@ class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} + if user_input is not None: unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() + if not self._reauth_entry: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() info = None try: info = await validate_input(self.hass, user_input) + entry_data = { + **user_input, + CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + } except CannotConnect: errors["base"] = "cannot_connect" if info: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self._reauth_entry.entry_id + ) + ) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=info["title"], - data={ - **user_input, - CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), - }, + data=entry_data, ) - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, self._reauth_entry.data if self._reauth_entry else {} ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 0f03505ff2..ae139d4c64 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -12,6 +12,7 @@ from httpx import HTTPError, HTTPStatusError, TimeoutException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN @@ -65,8 +66,9 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): 401, 403, ): - # Clear refresh token only on issues with authorization + # Clear refresh token only and trigger reauth self._update_config_entry_refresh_token(None) + raise ConfigEntryAuthFailed(str(err)) from err raise UpdateFailed(f"Error communicating with BMW API: {err}") from err if self.account.refresh_token != old_refresh_token: diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 3e93cccb8c..506175becd 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -14,7 +14,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index e441be8826..4db57ad302 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -1,4 +1,5 @@ """Test the for the BMW Connected Drive config flow.""" +from copy import deepcopy from unittest.mock import patch from bimmer_connected.api.authentication import MyBMWAuthentication @@ -10,7 +11,7 @@ from homeassistant.components.bmw_connected_drive.const import ( CONF_READ_ONLY, CONF_REFRESH_TOKEN, ) -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from . import FIXTURE_CONFIG_ENTRY, FIXTURE_REFRESH_TOKEN, FIXTURE_USER_INPUT @@ -110,3 +111,51 @@ async def test_options_flow_implementation(hass: HomeAssistant) -> None: } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test the reauth form.""" + with patch( + "bimmer_connected.api.authentication.MyBMWAuthentication.login", + side_effect=login_sideeffect, + autospec=True, + ), patch( + "homeassistant.components.bmw_connected_drive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + wrong_password = "wrong" + + config_entry_with_wrong_password = deepcopy(FIXTURE_CONFIG_ENTRY) + config_entry_with_wrong_password["data"][CONF_PASSWORD] = wrong_password + + config_entry = MockConfigEntry(**config_entry_with_wrong_password) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data == config_entry_with_wrong_password["data"] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data == FIXTURE_COMPLETE_ENTRY + + assert len(mock_setup_entry.mock_calls) == 1 From fd460996ba8f618a828b847cd9b09082c5cb23dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 09:21:05 -1000 Subject: [PATCH 0126/1165] Bump onvif-zeep-async to 1.2.2 to fix memory leak (#90216) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index db9f76189d..4b998bdd6c 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.2.1", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==1.2.2", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf804be500..6c51034376 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1263,7 +1263,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.2.1 +onvif-zeep-async==1.2.2 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0ee47f771..c4c70e7be2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -935,7 +935,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.2.1 +onvif-zeep-async==1.2.2 # homeassistant.components.opengarage open-garage==0.2.0 From 668b2726fe599972aa9ca96b0c5e3d4f962c4839 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 10:12:48 -1000 Subject: [PATCH 0127/1165] Bump yalexs-ble to 2.1.4 (#90276) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 7bbc6f042e..4e05224495 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.2"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.4"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index bb95a70386..37f148a45c 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.2"] + "requirements": ["yalexs-ble==2.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6c51034376..3e6213bca1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2671,7 +2671,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.2 +yalexs-ble==2.1.4 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4c70e7be2..1a572d5ccb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.2 +yalexs-ble==2.1.4 # homeassistant.components.august yalexs==1.2.7 From 5c839e23679e78fc951d797180d66c2158113bf1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Mar 2023 23:06:03 +0100 Subject: [PATCH 0128/1165] Add entity name translations to Elgato (#89629) --- homeassistant/components/elgato/button.py | 4 +-- homeassistant/components/elgato/sensor.py | 10 +++--- homeassistant/components/elgato/strings.json | 35 +++++++++++++++++++ homeassistant/components/elgato/switch.py | 4 +-- .../elgato/snapshots/test_button.ambr | 4 +-- .../elgato/snapshots/test_sensor.ambr | 10 +++--- .../elgato/snapshots/test_switch.ambr | 4 +-- 7 files changed, 53 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 0dd602f1ec..97673a79b9 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -40,14 +40,14 @@ class ElgatoButtonEntityDescription( BUTTONS = [ ElgatoButtonEntityDescription( key="identify", - name="Identify", + translation_key="identify", icon="mdi:help", entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.identify(), ), ElgatoButtonEntityDescription( key="restart", - name="Restart", + translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.restart(), diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 2692cf1085..371840de01 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -45,7 +45,7 @@ class ElgatoSensorEntityDescription( SENSORS = [ ElgatoSensorEntityDescription( key="battery", - name="Battery", + translation_key="battery", device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -56,7 +56,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="voltage", - name="Battery voltage", + translation_key="voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -69,7 +69,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="input_charge_current", - name="Charging current", + translation_key="input_charge_current", entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -82,7 +82,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="charge_power", - name="Charging power", + translation_key="charge_power", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -94,7 +94,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="input_charge_voltage", - name="Charging voltage", + translation_key="input_charge_voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index fc0007ac30..c5fc016aeb 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -21,5 +21,40 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "button": { + "identify": { + "name": "Identify" + }, + "restart": { + "name": "[%key:component::button::entity_component::restart::name%]" + } + }, + "sensor": { + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "charge_power": { + "name": "Charging power" + }, + "input_charge_current": { + "name": "Charging current" + }, + "input_charge_voltage": { + "name": "Charging voltage" + }, + "voltage": { + "name": "Battery voltage" + } + }, + "switch": { + "bypass": { + "name": "Studio mode" + }, + "energy_saving": { + "name": "Energy saving" + } + } } } diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index 0015909971..78af3adfa5 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -39,7 +39,7 @@ class ElgatoSwitchEntityDescription( SWITCHES = [ ElgatoSwitchEntityDescription( key="bypass", - name="Studio mode", + translation_key="bypass", icon="mdi:battery-off-outline", entity_category=EntityCategory.CONFIG, has_fn=lambda x: x.battery is not None, @@ -48,7 +48,7 @@ SWITCHES = [ ), ElgatoSwitchEntityDescription( key="energy_saving", - name="Energy saving", + translation_key="energy_saving", icon="mdi:leaf", entity_category=EntityCategory.CONFIG, has_fn=lambda x: x.battery is not None, diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index 900a3f316f..cb420c486b 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -37,7 +37,7 @@ 'original_name': 'Identify', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'identify', 'unique_id': 'GW24L1A02987_identify', 'unit_of_measurement': None, }) @@ -111,7 +111,7 @@ 'original_name': 'Restart', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'restart', 'unique_id': 'GW24L1A02987_restart', 'unit_of_measurement': None, }) diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index fa22ca1dfa..35429b8a32 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -44,7 +44,7 @@ 'original_name': 'Battery', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'battery', 'unique_id': 'GW24L1A02987_battery', 'unit_of_measurement': '%', }) @@ -128,7 +128,7 @@ 'original_name': 'Battery voltage', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': 'GW24L1A02987_voltage', 'unit_of_measurement': , }) @@ -212,7 +212,7 @@ 'original_name': 'Charging current', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'input_charge_current', 'unique_id': 'GW24L1A02987_input_charge_current', 'unit_of_measurement': , }) @@ -293,7 +293,7 @@ 'original_name': 'Charging power', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'charge_power', 'unique_id': 'GW24L1A02987_charge_power', 'unit_of_measurement': , }) @@ -377,7 +377,7 @@ 'original_name': 'Charging voltage', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'input_charge_voltage', 'unique_id': 'GW24L1A02987_input_charge_voltage', 'unit_of_measurement': , }) diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index 02f32d22f9..dcba00c0a9 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -37,7 +37,7 @@ 'original_name': 'Energy saving', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'energy_saving', 'unique_id': 'GW24L1A02987_energy_saving', 'unit_of_measurement': None, }) @@ -111,7 +111,7 @@ 'original_name': 'Studio mode', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'bypass', 'unique_id': 'GW24L1A02987_bypass', 'unit_of_measurement': None, }) From 89d00ac733588e90ef9cc25730eebf19f1d3e7d0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 25 Mar 2023 23:31:01 +0100 Subject: [PATCH 0129/1165] Fix default ipv6 resolver (#90269) --- homeassistant/components/dnsip/const.py | 2 +- tests/components/dnsip/test_config_flow.py | 14 +++++++------- tests/components/dnsip/test_init.py | 2 +- tests/components/dnsip/test_sensor.py | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/dnsip/const.py b/homeassistant/components/dnsip/const.py index a4f2c2fee2..56215d3d9a 100644 --- a/homeassistant/components/dnsip/const.py +++ b/homeassistant/components/dnsip/const.py @@ -15,4 +15,4 @@ DEFAULT_HOSTNAME = "myip.opendns.com" DEFAULT_IPV6 = False DEFAULT_NAME = "myip" DEFAULT_RESOLVER = "208.67.222.222" -DEFAULT_RESOLVER_IPV6 = "2620:0:ccc::2" +DEFAULT_RESOLVER_IPV6 = "2620:119:53::53" diff --git a/tests/components/dnsip/test_config_flow.py b/tests/components/dnsip/test_config_flow.py index 990fd4df15..7e219326ee 100644 --- a/tests/components/dnsip/test_config_flow.py +++ b/tests/components/dnsip/test_config_flow.py @@ -60,7 +60,7 @@ async def test_form(hass: HomeAssistant) -> None: } assert result2["options"] == { "resolver": "208.67.222.222", - "resolver_ipv6": "2620:0:ccc::2", + "resolver_ipv6": "2620:119:53::53", } assert len(mock_setup_entry.mock_calls) == 1 @@ -87,7 +87,7 @@ async def test_form_adv(hass: HomeAssistant) -> None: { CONF_HOSTNAME: "home-assistant.io", CONF_RESOLVER: "8.8.8.8", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, ) await hass.async_block_till_done() @@ -102,7 +102,7 @@ async def test_form_adv(hass: HomeAssistant) -> None: } assert result2["options"] == { "resolver": "8.8.8.8", - "resolver_ipv6": "2620:0:ccc::2", + "resolver_ipv6": "2620:119:53::53", } assert len(mock_setup_entry.mock_calls) == 1 @@ -143,7 +143,7 @@ async def test_flow_already_exist(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", }, unique_id="home-assistant.io", ).add_to_hass(hass) @@ -185,7 +185,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", }, ) entry.add_to_hass(hass) @@ -227,7 +227,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOSTNAME: "home-assistant.io", CONF_NAME: "home-assistant.io", CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", CONF_IPV4: True, CONF_IPV6: False, }, @@ -235,7 +235,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOSTNAME: "home-assistant.io", CONF_NAME: "home-assistant.io", CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", CONF_IPV4: False, CONF_IPV6: True, }, diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 1c8cf04c78..2869f13ca8 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -34,7 +34,7 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, entry_id="1", unique_id="home-assistant.io", diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index f44d58d212..75e5f5ebf8 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -37,7 +37,7 @@ async def test_sensor(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, entry_id="1", unique_id="home-assistant.io", @@ -71,7 +71,7 @@ async def test_sensor_no_response(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, entry_id="1", unique_id="home-assistant.io", From 7f6406127ead60ebeecfab5f3921a1c5556fbd42 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 25 Mar 2023 23:43:44 +0100 Subject: [PATCH 0130/1165] Remove platform yaml radiotherm (#90284) --- .../components/radiotherm/climate.py | 70 +------------------ .../components/radiotherm/config_flow.py | 19 ----- .../components/radiotherm/strings.json | 6 -- .../components/radiotherm/test_config_flow.py | 39 +---------- 4 files changed, 3 insertions(+), 131 deletions(-) diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index a800061b58..2c71eac019 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -1,17 +1,14 @@ """Support for Radio Thermostat wifi-enabled home thermostats.""" from __future__ import annotations -import logging from typing import Any import radiotherm -import voluptuous as vol from homeassistant.components.climate import ( FAN_AUTO, FAN_OFF, FAN_ON, - PLATFORM_SCHEMA, PRESET_AWAY, PRESET_HOME, ClimateEntity, @@ -19,25 +16,15 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_HOST, - PRECISION_HALVES, - UnitOfTemperature, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN from .coordinator import RadioThermUpdateCoordinator from .entity import RadioThermostatEntity -_LOGGER = logging.getLogger(__name__) - ATTR_FAN_ACTION = "fan_action" PRESET_HOLIDAY = "holiday" @@ -102,14 +89,6 @@ def round_temp(temperature): return round(temperature * 2.0) / 2.0 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean, - } -) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -120,51 +99,6 @@ async def async_setup_entry( async_add_entities([RadioThermostat(coordinator)]) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Radio Thermostat.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - _LOGGER.warning( - "Configuration of the Radio Thermostat climate platform in YAML is deprecated" - " and will be removed in Home Assistant 2022.9; Your existing configuration has" - " been imported into the UI automatically and can be safely removed from your" - " configuration.yaml file" - ) - - hosts: list[str] = [] - if CONF_HOST in config: - hosts = config[CONF_HOST] - else: - hosts.append( - await hass.async_add_executor_job(radiotherm.discover.discover_address) - ) - - if not hosts: - _LOGGER.error("No Radiotherm Thermostats detected") - return - - for host in hosts: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: host}, - ) - ) - - class RadioThermostat(RadioThermostatEntity, ClimateEntity): """Representation of a Radio Thermostat.""" diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index a3acc2e438..ca488ade46 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -83,25 +83,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders=placeholders, ) - async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: - """Import from yaml.""" - host = import_info[CONF_HOST] - self._async_abort_entries_match({CONF_HOST: host}) - _LOGGER.debug("Importing entry for host: %s", host) - try: - init_data = await validate_connection(self.hass, host) - except CannotConnect as ex: - _LOGGER.debug("Importing failed for %s", host, exc_info=ex) - return self.async_abort(reason="cannot_connect") - await self.async_set_unique_id(init_data.mac, raise_on_progress=False) - self._abort_if_unique_id_configured( - updates={CONF_HOST: host}, reload_on_update=False - ) - return self.async_create_entry( - title=init_data.name, - data={CONF_HOST: import_info[CONF_HOST]}, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index f0b31cdb4d..21f53d72bf 100644 --- a/homeassistant/components/radiotherm/strings.json +++ b/homeassistant/components/radiotherm/strings.json @@ -19,12 +19,6 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, - "issues": { - "deprecated_yaml": { - "title": "The Radio Thermostat YAML configuration is being removed", - "description": "Configuring the Radio Thermostat climate platform using YAML is being removed in Home Assistant 2022.9.\n\nYour existing configuration has been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "options": { "step": { "init": { diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py index 053bca0aa6..5625a50a4c 100644 --- a/tests/components/radiotherm/test_config_flow.py +++ b/tests/components/radiotherm/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Radio Thermostat config flow.""" -import socket + from unittest.mock import MagicMock, patch from radiotherm import CommonThermostat @@ -98,43 +98,6 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {CONF_HOST: "cannot_connect"} -async def test_import(hass: HomeAssistant) -> None: - """Test we get can import from yaml.""" - with patch( - "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", - return_value=_mock_radiotherm(), - ), patch( - "homeassistant.components.radiotherm.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.2.3.4"}, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "My Name" - assert result["data"] == {CONF_HOST: "1.2.3.4"} - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_cannot_connect(hass: HomeAssistant) -> None: - """Test we abort if we cannot connect on import from yaml.""" - with patch( - "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", - side_effect=socket.timeout, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.2.3.4"}, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - async def test_dhcp_can_confirm(hass: HomeAssistant) -> None: """Test DHCP discovery flow can confirm right away.""" From 92beb48a415333d793e2bc1bdefe987876ef8230 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 00:24:43 +0100 Subject: [PATCH 0131/1165] Add sensor platform to Sun (#81045) * Sun sensor * remove extra attr * Add tests * Add back attributes * position sensors disabled default * entity id * unique id * test init to attributes * Fix test init * Fix test sensor * test unique id * uom * remove rising * Remove not needed uom property * Fix reload issue * degree --- homeassistant/components/sun/__init__.py | 13 ++- homeassistant/components/sun/sensor.py | 133 +++++++++++++++++++++++ tests/components/sun/test_init.py | 7 +- tests/components/sun/test_sensor.py | 101 +++++++++++++++++ 4 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/sun/sensor.py create mode 100644 tests/components/sun/test_sensor.py diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 65836e0c61..a43bf4fd80 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( EVENT_CORE_CONFIG_UPDATE, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, + Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import event @@ -97,15 +98,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # we will create entities before firing EVENT_COMPONENT_LOADED await async_process_integration_platform_for_component(hass, DOMAIN) hass.data[DOMAIN] = Sun(hass) + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - sun = hass.data.pop(DOMAIN) - sun.remove_listeners() - hass.states.async_remove(sun.entity_id) - return True + if unload_ok := await hass.config_entries.async_unload_platforms( + entry, [Platform.SENSOR] + ): + sun: Sun = hass.data.pop(DOMAIN) + sun.remove_listeners() + hass.states.async_remove(sun.entity_id) + return unload_ok class Sun(Entity): diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py new file mode 100644 index 0000000000..527ccc4069 --- /dev/null +++ b/homeassistant/components/sun/sensor.py @@ -0,0 +1,133 @@ +"""Sensor platform for Sun integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEGREE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import Sun +from .const import DOMAIN + +ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" + + +@dataclass +class SunEntityDescriptionMixin: + """Mixin for required Sun base description keys.""" + + value_fn: Callable[[Sun], StateType | datetime] + + +@dataclass +class SunSensorEntityDescription(SensorEntityDescription, SunEntityDescriptionMixin): + """Describes Sun sensor entity.""" + + +SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( + SunSensorEntityDescription( + key="next_dawn", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next dawn", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dawn, + ), + SunSensorEntityDescription( + key="next_dusk", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next dusk", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dusk, + ), + SunSensorEntityDescription( + key="next_midnight", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next midnight", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_midnight, + ), + SunSensorEntityDescription( + key="next_noon", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next noon", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_noon, + ), + SunSensorEntityDescription( + key="next_rising", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next rising", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_rising, + ), + SunSensorEntityDescription( + key="next_setting", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next setting", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_setting, + ), + SunSensorEntityDescription( + key="solar_elevation", + name="Solar elevation", + icon="mdi:theme-light-dark", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_elevation, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), + SunSensorEntityDescription( + key="solar_azimuth", + name="Solar azimuth", + icon="mdi:sun-angle", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_azimuth, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sun sensor platform.""" + + sun: Sun = hass.data[DOMAIN] + + async_add_entities( + [SunSensor(sun, description, entry.entry_id) for description in SENSOR_TYPES] + ) + + +class SunSensor(SensorEntity): + """Representation of a Sun Sensor.""" + + entity_description: SunSensorEntityDescription + + def __init__( + self, sun: Sun, entity_description: SunSensorEntityDescription, entry_id: str + ) -> None: + """Initiate Sun Sensor.""" + self.entity_description = entity_description + self.entity_id = ENTITY_ID_SENSOR_FORMAT.format(entity_description.key) + self._attr_unique_id = f"{entry_id}-{entity_description.key}" + self.sun = sun + + @property + def native_value(self) -> StateType | datetime: + """Return value of sensor.""" + state = self.entity_description.value_fn(self.sun) + return state diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index 2795330bf7..fef9bd4e04 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -5,10 +5,9 @@ from unittest.mock import patch from freezegun import freeze_time import pytest -import homeassistant.components.sun as sun +from homeassistant.components import sun from homeassistant.const import EVENT_STATE_CHANGED -import homeassistant.core as ha -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -196,7 +195,7 @@ async def test_state_change_count(hass: HomeAssistant) -> None: events = [] - @ha.callback + @callback def state_change_listener(event): if event.data.get("entity_id") == "sun.sun": events.append(event) diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py new file mode 100644 index 0000000000..13f4fd0d62 --- /dev/null +++ b/tests/components/sun/test_sensor.py @@ -0,0 +1,101 @@ +"""The tests for the Sun sensor platform.""" +from datetime import datetime, timedelta + +from astral import LocationInfo +import astral.sun +from freezegun import freeze_time + +from homeassistant.components import sun +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + + +async def test_setting_rising(hass: HomeAssistant) -> None: + """Test retrieving sun setting and rising.""" + utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(utc_now): + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) + + await hass.async_block_till_done() + + utc_today = utc_now.date() + + location = LocationInfo( + latitude=hass.config.latitude, longitude=hass.config.longitude + ) + + mod = -1 + while True: + next_dawn = astral.sun.dawn( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_dawn > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_dusk = astral.sun.dusk( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_dusk > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_midnight = astral.sun.midnight( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_midnight > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_noon = astral.sun.noon( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_noon > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_rising = astral.sun.sunrise( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_rising > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_setting = astral.sun.sunset( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_setting > utc_now: + break + mod += 1 + + state1 = hass.states.get("sensor.sun_next_dawn") + state2 = hass.states.get("sensor.sun_next_dusk") + state3 = hass.states.get("sensor.sun_next_midnight") + state4 = hass.states.get("sensor.sun_next_noon") + state5 = hass.states.get("sensor.sun_next_rising") + state6 = hass.states.get("sensor.sun_next_setting") + assert next_dawn.replace(microsecond=0) == dt_util.parse_datetime(state1.state) + assert next_dusk.replace(microsecond=0) == dt_util.parse_datetime(state2.state) + assert next_midnight.replace(microsecond=0) == dt_util.parse_datetime(state3.state) + assert next_noon.replace(microsecond=0) == dt_util.parse_datetime(state4.state) + assert next_rising.replace(microsecond=0) == dt_util.parse_datetime(state5.state) + assert next_setting.replace(microsecond=0) == dt_util.parse_datetime(state6.state) + + entry_ids = hass.config_entries.async_entries("sun") + + entity_reg = er.async_get(hass) + entity = entity_reg.async_get("sensor.sun_next_dawn") + + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_dawn" From 255f12ec05d7e70f9617df78af1a2153f08c32dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 15:48:03 -1000 Subject: [PATCH 0132/1165] Bump bleak-retry-connector to 3.0.2 (#90279) changelog: https://github.com/Bluetooth-Devices/bleak-retry-connector/compare/v3.0.1...v3.0.2 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f6cbe5b3e5..87a584f0be 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.20.0", - "bleak-retry-connector==3.0.1", + "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.0.3", "bluetooth-data-tools==0.3.1", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2f0cff1319..4af85540de 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ atomicwrites-homeassistant==1.4.1 attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 -bleak-retry-connector==3.0.1 +bleak-retry-connector==3.0.2 bleak==0.20.0 bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.0.3 diff --git a/requirements_all.txt b/requirements_all.txt index 3e6213bca1..c1a05be4e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,7 +431,7 @@ bimmer_connected==0.13.0 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.0.1 +bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth bleak==0.20.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a572d5ccb..f2c15b7771 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -361,7 +361,7 @@ bellows==0.34.10 bimmer_connected==0.13.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.0.1 +bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth bleak==0.20.0 From 53726cb4a194f61d8a9284ce7b64ff6b6743e6c6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 04:02:10 +0200 Subject: [PATCH 0133/1165] Remove Magicseaweed (#90277) --- .coveragerc | 1 - .../components/magicseaweed/__init__.py | 1 - .../components/magicseaweed/manifest.json | 9 - .../components/magicseaweed/sensor.py | 227 ------------------ .../components/magicseaweed/strings.json | 8 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - 7 files changed, 255 deletions(-) delete mode 100644 homeassistant/components/magicseaweed/__init__.py delete mode 100644 homeassistant/components/magicseaweed/manifest.json delete mode 100644 homeassistant/components/magicseaweed/sensor.py delete mode 100644 homeassistant/components/magicseaweed/strings.json diff --git a/.coveragerc b/.coveragerc index 24f324313a..f3dbc34791 100644 --- a/.coveragerc +++ b/.coveragerc @@ -678,7 +678,6 @@ omit = homeassistant/components/lyric/api.py homeassistant/components/lyric/climate.py homeassistant/components/lyric/sensor.py - homeassistant/components/magicseaweed/sensor.py homeassistant/components/mailgun/notify.py homeassistant/components/map/* homeassistant/components/mastodon/notify.py diff --git a/homeassistant/components/magicseaweed/__init__.py b/homeassistant/components/magicseaweed/__init__.py deleted file mode 100644 index 848d02967f..0000000000 --- a/homeassistant/components/magicseaweed/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The magicseaweed component.""" diff --git a/homeassistant/components/magicseaweed/manifest.json b/homeassistant/components/magicseaweed/manifest.json deleted file mode 100644 index 4858e6be4f..0000000000 --- a/homeassistant/components/magicseaweed/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "magicseaweed", - "name": "Magicseaweed", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/magicseaweed", - "iot_class": "cloud_polling", - "loggers": ["magicseaweed"], - "requirements": ["magicseaweed==1.0.3"] -} diff --git a/homeassistant/components/magicseaweed/sensor.py b/homeassistant/components/magicseaweed/sensor.py deleted file mode 100644 index aa59553ef8..0000000000 --- a/homeassistant/components/magicseaweed/sensor.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Support for magicseaweed data from magicseaweed.com.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import magicseaweed -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import METRIC_SYSTEM - -_LOGGER = logging.getLogger(__name__) - -CONF_HOURS = "hours" -CONF_SPOT_ID = "spot_id" -CONF_UNITS = "units" - -DEFAULT_UNIT = "us" -DEFAULT_NAME = "MSW" - -ICON = "mdi:waves" - -HOURS = ["12AM", "3AM", "6AM", "9AM", "12PM", "3PM", "6PM", "9PM"] - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="max_breaking_swell", - name="Max", - ), - SensorEntityDescription( - key="min_breaking_swell", - name="Min", - ), - SensorEntityDescription( - key="swell_forecast", - name="Forecast", - ), -) - -SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] - - -UNITS = ["eu", "uk", "us"] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(SENSOR_KEYS)] - ), - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_SPOT_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_HOURS, default=None): vol.All( - cv.ensure_list, [vol.In(HOURS)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNITS): vol.In(UNITS), - } -) - -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Magicseaweed sensor.""" - create_issue( - hass, - "magicseaweed", - "pending_removal", - breaks_in_ha_version="2023.3.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="pending_removal", - ) - _LOGGER.warning( - "The Magicseaweed integration is deprecated" - " and will be removed in Home Assistant 2023.3" - ) - - name = config.get(CONF_NAME) - spot_id = config[CONF_SPOT_ID] - api_key = config[CONF_API_KEY] - hours = config.get(CONF_HOURS) - - if CONF_UNITS in config: - units = config.get(CONF_UNITS) - elif hass.config.units is METRIC_SYSTEM: - units = UNITS[0] - else: - units = UNITS[2] - - forecast_data = MagicSeaweedData(api_key=api_key, spot_id=spot_id, units=units) - forecast_data.update() - - # If connection failed don't setup platform. - if forecast_data.currently is None or forecast_data.hourly is None: - return - - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - sensors = [ - MagicSeaweedSensor(forecast_data, name, units, description) - for description in SENSOR_TYPES - if description.key in monitored_conditions - ] - if hours is not None: - sensors.extend( - [ - MagicSeaweedSensor(forecast_data, name, units, description, hour) - for description in SENSOR_TYPES - if description.key in monitored_conditions - and "forecast" not in description.key - for hour in hours - ] - ) - add_entities(sensors, True) - - -class MagicSeaweedSensor(SensorEntity): - """Implementation of a MagicSeaweed sensor.""" - - _attr_attribution = "Data provided by magicseaweed.com" - _attr_icon = ICON - - def __init__( - self, - forecast_data, - name, - unit_system, - description: SensorEntityDescription, - hour=None, - ) -> None: - """Initialize the sensor.""" - self.entity_description = description - self.client_name = name - self.data = forecast_data - self.hour = hour - self._unit_system = unit_system - - if hour is None and "forecast" in description.key: - self._attr_name = f"{name} {description.name}" - elif hour is None: - self._attr_name = f"Current {name} {description.name}" - else: - self._attr_name = f"{hour} {name} {description.name}" - - self._attr_extra_state_attributes = {} - - @property - def unit_system(self): - """Return the unit system of this entity.""" - return self._unit_system - - def update(self) -> None: - """Get the latest data from Magicseaweed and updates the states.""" - self.data.update() - if self.hour is None: - forecast = self.data.currently - else: - forecast = self.data.hourly[self.hour] - - self._attr_native_unit_of_measurement = forecast.swell_unit - sensor_type = self.entity_description.key - if sensor_type == "min_breaking_swell": - self._attr_native_value = forecast.swell_minBreakingHeight - elif sensor_type == "max_breaking_swell": - self._attr_native_value = forecast.swell_maxBreakingHeight - elif sensor_type == "swell_forecast": - summary = ( - f"{forecast.swell_minBreakingHeight} -" - f" {forecast.swell_maxBreakingHeight}" - ) - self._attr_native_value = summary - if self.hour is None: - for hour, data in self.data.hourly.items(): - occurs = hour - hr_summary = ( - f"{data.swell_minBreakingHeight} -" - f" {data.swell_maxBreakingHeight} {data.swell_unit}" - ) - self._attr_extra_state_attributes[occurs] = hr_summary - - if sensor_type != "swell_forecast": - self._attr_extra_state_attributes.update(forecast.attrs) - - -class MagicSeaweedData: - """Get the latest data from MagicSeaweed.""" - - def __init__(self, api_key, spot_id, units): - """Initialize the data object.""" - self._msw = magicseaweed.MSW_Forecast(api_key, spot_id, None, units) - self.currently = None - self.hourly = {} - - # Apply throttling to methods using configured interval - self.update = Throttle(MIN_TIME_BETWEEN_UPDATES)(self._update) - - def _update(self): - """Get the latest data from MagicSeaweed.""" - try: - forecasts = self._msw.get_future() - self.currently = forecasts.data[0] - for forecast in forecasts.data[:8]: - hour = dt_util.utc_from_timestamp(forecast.localTimestamp).strftime( - "%-I%p" - ) - self.hourly[hour] = forecast - except ConnectionError: - _LOGGER.error("Unable to retrieve data from Magicseaweed") diff --git a/homeassistant/components/magicseaweed/strings.json b/homeassistant/components/magicseaweed/strings.json deleted file mode 100644 index 0aa8a58419..0000000000 --- a/homeassistant/components/magicseaweed/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "pending_removal": { - "title": "The Magicseaweed integration is being removed", - "description": "The Magicseaweed integration is pending removal from Home Assistant and will no longer be available as of Home Assistant 2023.3.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index efd1899c5b..843b8ed006 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3078,12 +3078,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "magicseaweed": { - "name": "Magicseaweed", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" - }, "mailgun": { "name": "Mailgun", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index c1a05be4e5..5da7cafec3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1092,9 +1092,6 @@ lxml==4.9.1 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 -# homeassistant.components.magicseaweed -magicseaweed==1.0.3 - # homeassistant.components.matrix matrix-client==0.4.0 From 5f59bab9ecb75fd5686899f4d9cedd1c3d63168f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 16:29:26 -1000 Subject: [PATCH 0134/1165] Bump bleak to 0.20.1 (#90282) Co-authored-by: Charles Garwood --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 87a584f0be..31b9bdb5d5 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.20.0", + "bleak==0.20.1", "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.0.3", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4af85540de..c91ab060b5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.0.2 -bleak==0.20.0 +bleak==0.20.1 bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 diff --git a/requirements_all.txt b/requirements_all.txt index 5da7cafec3..8517577dd1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -434,7 +434,7 @@ bizkaibus==0.1.1 bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth -bleak==0.20.0 +bleak==0.20.1 # homeassistant.components.blebox blebox_uniapi==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2c15b7771..2cd15e9af9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -364,7 +364,7 @@ bimmer_connected==0.13.0 bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth -bleak==0.20.0 +bleak==0.20.1 # homeassistant.components.blebox blebox_uniapi==2.1.4 From bd08d888123e17bb1b21550267355003613b26ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 17:27:35 -1000 Subject: [PATCH 0135/1165] Bump yalexs-ble to 2.1.5 (#90287) Bump yalexs-ble 2.1.5 Some of the lever locks need a bit longer debounce time since they still report stale state for up to 6s changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.4...v2.1.5 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 4e05224495..3db09bdc34 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.4"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.5"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 37f148a45c..ba314ecf2a 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.4"] + "requirements": ["yalexs-ble==2.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8517577dd1..66edf53480 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.4 +yalexs-ble==2.1.5 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cd15e9af9..bf5998ed50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.4 +yalexs-ble==2.1.5 # homeassistant.components.august yalexs==1.2.7 From 0b8fb36a7e6183309b621ef4f8bf00e7569e80ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 17:28:38 -1000 Subject: [PATCH 0136/1165] Fix onvif binary sensors (#90202) * Fix httpx client creating a new ssl context with each client While working on https://github.com/home-assistant/core/issues/83524 it was discovered that each new httpx client creates a new ssl context https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_transports/default.py#L261 If an ssl context is passed in creating a new one is avoided here https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_config.py#L110 This change makes httpx ssl no-verify behavior match aiohttp ssl no-verify behavior https://github.com/aio-libs/aiohttp/blob/6da04694fd87a39af9c3856048c9ff23ca815f88/aiohttp/connector.py#L892 aiohttp solved this by wrapping the code that generates the ssl context in an lru_cache * compact * Fix onvif binary sensors fixes #83524 needs https://github.com/hunterjm/python-onvif-zeep-async/pull/9 first to avoid recreating the memory leak * Fix memory leak in onvif Work around until https://github.com/hunterjm/python-onvif-zeep-async/pull/9 followup to https://github.com/home-assistant/core/pull/83006 * move check * onvif-zeep-async 1.2.2 * fix unloading --- homeassistant/components/onvif/event.py | 58 +++++++++++++++---------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 54c5b3b007..84d75bf804 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -27,6 +27,13 @@ SUBSCRIPTION_ERRORS = ( ) +def _stringify_onvif_error(error: Exception) -> str: + """Stringify ONVIF error.""" + if isinstance(error, Fault): + return error.message or str(error) or "Device sent empty error" + return str(error) + + class EventManager: """ONVIF Event Manager.""" @@ -79,30 +86,30 @@ class EventManager: async def async_start(self) -> bool: """Start polling events.""" - if await self.device.create_pullpoint_subscription(): - # Create subscription manager - self._subscription = self.device.create_subscription_service( - "PullPointSubscription" - ) + if not await self.device.create_pullpoint_subscription(): + return False - # Renew immediately - await self.async_renew() + # Create subscription manager + self._subscription = self.device.create_subscription_service( + "PullPointSubscription" + ) - # Initialize events - pullpoint = self.device.create_pullpoint_service() - with suppress(*SUBSCRIPTION_ERRORS): - await pullpoint.SetSynchronizationPoint() - response = await pullpoint.PullMessages( - {"MessageLimit": 100, "Timeout": dt.timedelta(seconds=5)} - ) + # Renew immediately + await self.async_renew() - # Parse event initialization - await self.async_parse_messages(response.NotificationMessage) + # Initialize events + pullpoint = self.device.create_pullpoint_service() + with suppress(*SUBSCRIPTION_ERRORS): + await pullpoint.SetSynchronizationPoint() + response = await pullpoint.PullMessages( + {"MessageLimit": 100, "Timeout": dt.timedelta(seconds=5)} + ) - self.started = True - return True + # Parse event initialization + await self.async_parse_messages(response.NotificationMessage) - return False + self.started = True + return True async def async_stop(self) -> None: """Unsubscribe from events.""" @@ -112,7 +119,8 @@ class EventManager: if not self._subscription: return - await self._subscription.Unsubscribe() + with suppress(*SUBSCRIPTION_ERRORS): + await self._subscription.Unsubscribe() self._subscription = None async def async_restart(self, _now: dt.datetime | None = None) -> None: @@ -148,7 +156,7 @@ class EventManager: "Retrying later: %s" ), self.unique_id, - err, + _stringify_onvif_error(err), ) if not restarted: @@ -170,7 +178,11 @@ class EventManager: .isoformat(timespec="seconds") .replace("+00:00", "Z") ) - await self._subscription.Renew(termination_time) + with suppress(*SUBSCRIPTION_ERRORS): + # The first time we renew, we may get a Fault error so we + # suppress it. The subscription will be restarted in + # async_restart later. + await self._subscription.Renew(termination_time) def async_schedule_pull(self) -> None: """Schedule async_pull_messages to run.""" @@ -203,7 +215,7 @@ class EventManager: " '%s': %s" ), self.unique_id, - err, + _stringify_onvif_error(err), ) # Treat errors as if the camera restarted. Assume that the pullpoint # subscription is no longer valid. From 40131d811c1f0af9040a9f46de25b47a8bce576c Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Sun, 26 Mar 2023 09:35:49 +0200 Subject: [PATCH 0137/1165] Handle Livisi TokenExpiredException (#90258) * reauth * Request new Token on TokenExpiredException * relogin using stored auth data * fix imports * import formatting --- homeassistant/components/livisi/coordinator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index f745a66e82..56e928307c 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -6,6 +6,7 @@ from typing import Any from aiohttp import ClientConnectorError from aiolivisi import AioLivisi, LivisiEvent, Websocket +from aiolivisi.errors import TokenExpiredException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -55,8 +56,11 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Get device configuration from LIVISI.""" try: return await self.async_get_devices() + except TokenExpiredException: + await self.aiolivisi.async_set_token(self.aiolivisi.livisi_connection_data) + return await self.async_get_devices() except ClientConnectorError as exc: - raise UpdateFailed("Failed to get LIVISI the devices") from exc + raise UpdateFailed("Failed to get livisi devices from controller") from exc def _async_dispatcher_send(self, event: str, source: str, data: Any) -> None: if data is not None: From b3f3f234c69cf276f0cea34150aa3726e5d3b77d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 09:40:07 +0200 Subject: [PATCH 0138/1165] Remove pushbullet platform yaml import (#90285) Depr pushbullet yaml --- .../components/pushbullet/config_flow.py | 5 -- homeassistant/components/pushbullet/notify.py | 30 ++---------- homeassistant/components/pushbullet/sensor.py | 49 ++----------------- .../components/pushbullet/strings.json | 6 --- .../components/pushbullet/test_config_flow.py | 13 ----- 5 files changed, 6 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/pushbullet/config_flow.py b/homeassistant/components/pushbullet/config_flow.py index e6259fa8ce..1eca2bd890 100644 --- a/homeassistant/components/pushbullet/config_flow.py +++ b/homeassistant/components/pushbullet/config_flow.py @@ -24,11 +24,6 @@ CONFIG_SCHEMA = vol.Schema( class PushBulletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for pushbullet integration.""" - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Handle import from config.""" - import_config[CONF_NAME] = import_config.get(CONF_NAME, DEFAULT_NAME) - return await self.async_step_user(import_config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index fcc9d00dc7..1cc851bdb9 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import mimetypes -from typing import Any +from typing import TYPE_CHECKING, Any from pushbullet import PushBullet, PushError from pushbullet.channel import Channel @@ -15,23 +15,16 @@ from homeassistant.components.notify import ( ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) - async def async_get_service( hass: HomeAssistant, @@ -39,25 +32,8 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> PushBulletNotificationService | None: """Get the Pushbullet notification service.""" - if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - return None - + if TYPE_CHECKING: + assert discovery_info is not None pushbullet: PushBullet = hass.data[DOMAIN][discovery_info["entry_id"]].pushbullet return PushBulletNotificationService(hass, pushbullet) diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index aef97991c6..b61469f6b2 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -1,23 +1,14 @@ """Pushbullet platform for sensor component.""" from __future__ import annotations -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .api import PushBulletNotificationProvider from .const import DATA_UPDATED, DOMAIN @@ -75,40 +66,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=["title", "body"]): vol.All( - cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_KEYS)] - ), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Pushbullet Sensor platform.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/homeassistant/components/pushbullet/strings.json b/homeassistant/components/pushbullet/strings.json index 92d22d117d..a6571ae7bf 100644 --- a/homeassistant/components/pushbullet/strings.json +++ b/homeassistant/components/pushbullet/strings.json @@ -15,11 +15,5 @@ } } } - }, - "issues": { - "deprecated_yaml": { - "title": "The Pushbullet YAML configuration is being removed", - "description": "Configuring Pushbullet using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Pushbullet YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/pushbullet/test_config_flow.py b/tests/components/pushbullet/test_config_flow.py index a19c424c8b..f250c22c44 100644 --- a/tests/components/pushbullet/test_config_flow.py +++ b/tests/components/pushbullet/test_config_flow.py @@ -119,16 +119,3 @@ async def test_flow_conn_error(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} - - -async def test_import(hass: HomeAssistant, requests_mock_fixture) -> None: - """Test user initialized flow with unreachable server.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "pushbullet" - assert result["data"] == MOCK_CONFIG From e8f3b9c09a58972f3f2af83b0c85f8f001db6523 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 09:42:38 +0200 Subject: [PATCH 0139/1165] Remove Volvooncall integration yaml import (#90288) Depr yaml import --- .../components/volvooncall/__init__.py | 73 +------------------ .../components/volvooncall/config_flow.py | 4 - .../components/volvooncall/strings.json | 6 -- .../volvooncall/test_config_flow.py | 39 ---------- 4 files changed, 1 insertion(+), 121 deletions(-) diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index b6d97dea21..ab4fa78111 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -4,28 +4,21 @@ import logging from aiohttp.client_exceptions import ClientResponseError import async_timeout -import voluptuous as vol from volvooncall import Connection from volvooncall.dashboard import Instrument -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_NAME, CONF_PASSWORD, CONF_REGION, - CONF_RESOURCES, - CONF_SCAN_INTERVAL, CONF_UNIT_SYSTEM, CONF_USERNAME, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -35,11 +28,9 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( CONF_MUTABLE, CONF_SCANDINAVIAN_MILES, - CONF_SERVICE_URL, DEFAULT_UPDATE_INTERVAL, DOMAIN, PLATFORMS, - RESOURCES, UNIT_SYSTEM_IMPERIAL, UNIT_SYSTEM_METRIC, UNIT_SYSTEM_SCANDINAVIAN_MILES, @@ -49,68 +40,6 @@ from .errors import InvalidAuth _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL - ): vol.All(cv.time_period, vol.Clamp(min=DEFAULT_UPDATE_INTERVAL)), - vol.Optional(CONF_NAME, default={}): cv.schema_with_slug_keys( - cv.string - ), - vol.Optional(CONF_RESOURCES): vol.All( - cv.ensure_list, [vol.In(RESOURCES)] - ), - vol.Optional(CONF_REGION): cv.string, - vol.Optional(CONF_SERVICE_URL): cv.string, - vol.Optional(CONF_MUTABLE, default=True): cv.boolean, - vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Migrate from YAML to ConfigEntry.""" - if DOMAIN not in config: - return True - - hass.data[DOMAIN] = {} - - if not hass.config_entries.async_entries(DOMAIN): - new_conf = {} - new_conf[CONF_USERNAME] = config[DOMAIN][CONF_USERNAME] - new_conf[CONF_PASSWORD] = config[DOMAIN][CONF_PASSWORD] - new_conf[CONF_REGION] = config[DOMAIN].get(CONF_REGION) - new_conf[CONF_SCANDINAVIAN_MILES] = config[DOMAIN][CONF_SCANDINAVIAN_MILES] - new_conf[CONF_MUTABLE] = config[DOMAIN][CONF_MUTABLE] - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=new_conf - ) - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version=None, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Volvo On Call component from a ConfigEntry.""" diff --git a/homeassistant/components/volvooncall/config_flow.py b/homeassistant/components/volvooncall/config_flow.py index c1b3ab3f66..d56d10ded5 100644 --- a/homeassistant/components/volvooncall/config_flow.py +++ b/homeassistant/components/volvooncall/config_flow.py @@ -106,10 +106,6 @@ class VolvoOnCallConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=user_schema, errors=errors ) - async def async_step_import(self, import_data) -> FlowResult: - """Import volvooncall config from configuration.yaml.""" - return await self.async_step_user(import_data) - async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( diff --git a/homeassistant/components/volvooncall/strings.json b/homeassistant/components/volvooncall/strings.json index 9e8471b04b..44b821b4b0 100644 --- a/homeassistant/components/volvooncall/strings.json +++ b/homeassistant/components/volvooncall/strings.json @@ -19,11 +19,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Volvo On Call YAML configuration is being removed", - "description": "Configuring the Volvo On Call platform using YAML is being removed in a future release of Home Assistant.\n\nYour existing configuration has been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/volvooncall/test_config_flow.py b/tests/components/volvooncall/test_config_flow.py index 549dc9d440..c8ed92d8ee 100644 --- a/tests/components/volvooncall/test_config_flow.py +++ b/tests/components/volvooncall/test_config_flow.py @@ -130,45 +130,6 @@ async def test_form_other_exception(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_import(hass: HomeAssistant) -> None: - """Test a YAML import.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] == FlowResultType.FORM - assert len(result["errors"]) == 0 - - with patch("volvooncall.Connection.get"), patch( - "homeassistant.components.volvooncall.async_setup", - return_value=True, - ), patch( - "homeassistant.components.volvooncall.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_reauth(hass: HomeAssistant) -> None: """Test that we handle the reauth flow.""" From a0b6da33ab4c2c11d1832342828ae041eaf5e91b Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 26 Mar 2023 09:57:13 +0200 Subject: [PATCH 0140/1165] Strict typing of UniFi integration (#90278) * Fix typing of UniFi controller * Strict typing of unifi.__init__ * Strict typing of UniFi config_flow * Strict typing of UniFi switch * Strict typing UniFi sensor * Strict typing UniFi device tracker * Strict typing of UniFi * Fix library issues related to typing --- .strict-typing | 2 +- homeassistant/components/unifi/__init__.py | 2 +- homeassistant/components/unifi/controller.py | 90 ++++++++++--------- .../components/unifi/device_tracker.py | 4 +- homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/switch.py | 5 +- mypy.ini | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 57 insertions(+), 54 deletions(-) diff --git a/.strict-typing b/.strict-typing index 9db9500892..533d5239ca 100644 --- a/.strict-typing +++ b/.strict-typing @@ -311,7 +311,7 @@ homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* -homeassistant.components.unifi.update +homeassistant.components.unifi.* homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index d6405d1171..a7e8aede36 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" - controller = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) + controller: UniFiController = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) if not hass.data[UNIFI_DOMAIN]: async_unload_services(hass) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index a5f3c4d772..60507d5a8c 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -10,7 +10,7 @@ from typing import Any from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent -from aiounifi.websocket import WebsocketSignal, WebsocketState +from aiounifi.websocket import WebsocketState import async_timeout from homeassistant.config_entries import ConfigEntry @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import ( aiohttp_client, device_registry as dr, @@ -75,31 +75,32 @@ CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) class UniFiController: """Manages a single UniFi Network instance.""" - def __init__(self, hass, config_entry, api): + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: aiounifi.Controller + ) -> None: """Initialize the system.""" self.hass = hass self.config_entry = config_entry self.api = api - api.callback = self.async_unifi_signalling_callback + api.ws_state_callback = self.async_unifi_ws_state_callback self.available = True self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] self.site_id: str = "" - self._site_name = None - self._site_role = None + self._site_name: str | None = None + self._site_role: str | None = None - self._cancel_heartbeat_check = None - self._heartbeat_dispatch = {} - self._heartbeat_time = {} + self._cancel_heartbeat_check: CALLBACK_TYPE | None = None + self._heartbeat_time: dict[str, datetime] = {} self.load_config_entry_options() - self.entities = {} + self.entities: dict[str, str] = {} self.known_objects: set[tuple[str, str]] = set() - def load_config_entry_options(self): + def load_config_entry_options(self) -> None: """Store attributes to avoid property call overhead since they are called frequently.""" options = self.config_entry.options @@ -114,7 +115,7 @@ class UniFiController: CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS ) # Config entry option to not track devices. - self.option_track_devices = options.get( + self.option_track_devices: bool = options.get( CONF_TRACK_DEVICES, DEFAULT_TRACK_DEVICES ) # Config entry option listing what SSIDs are being used to track clients. @@ -133,43 +134,45 @@ class UniFiController: # Config entry option with list of clients to control network access. self.option_block_clients = options.get(CONF_BLOCK_CLIENT, []) # Config entry option to control DPI restriction groups. - self.option_dpi_restrictions = options.get( + self.option_dpi_restrictions: bool = options.get( CONF_DPI_RESTRICTIONS, DEFAULT_DPI_RESTRICTIONS ) # Statistics sensor options # Config entry option to allow bandwidth sensors. - self.option_allow_bandwidth_sensors = options.get( + self.option_allow_bandwidth_sensors: bool = options.get( CONF_ALLOW_BANDWIDTH_SENSORS, DEFAULT_ALLOW_BANDWIDTH_SENSORS ) # Config entry option to allow uptime sensors. - self.option_allow_uptime_sensors = options.get( + self.option_allow_uptime_sensors: bool = options.get( CONF_ALLOW_UPTIME_SENSORS, DEFAULT_ALLOW_UPTIME_SENSORS ) @property - def host(self): + def host(self) -> str: """Return the host of this controller.""" - return self.config_entry.data[CONF_HOST] + host: str = self.config_entry.data[CONF_HOST] + return host @property - def site(self): + def site(self) -> str: """Return the site of this config entry.""" - return self.config_entry.data[CONF_SITE_ID] + site_id: str = self.config_entry.data[CONF_SITE_ID] + return site_id @property - def site_name(self): + def site_name(self) -> str | None: """Return the nice name of site.""" return self._site_name @property - def site_role(self): + def site_role(self) -> str | None: """Return the site user role of this controller.""" return self._site_role @property - def mac(self): + def mac(self) -> str | None: """Return the mac address of this controller.""" for client in self.api.clients.values(): if self.host == client.ip: @@ -227,22 +230,21 @@ class UniFiController: async_load_entities(description) @callback - def async_unifi_signalling_callback(self, signal, data): + def async_unifi_ws_state_callback(self, state: WebsocketState) -> None: """Handle messages back from UniFi library.""" - if signal == WebsocketSignal.CONNECTION_STATE: - if data == WebsocketState.DISCONNECTED and self.available: - LOGGER.warning("Lost connection to UniFi Network") + if state == WebsocketState.DISCONNECTED and self.available: + LOGGER.warning("Lost connection to UniFi Network") - if (data == WebsocketState.RUNNING and not self.available) or ( - data == WebsocketState.DISCONNECTED and self.available - ): - self.available = data == WebsocketState.RUNNING - async_dispatcher_send(self.hass, self.signal_reachable) + if (state == WebsocketState.RUNNING and not self.available) or ( + state == WebsocketState.DISCONNECTED and self.available + ): + self.available = state == WebsocketState.RUNNING + async_dispatcher_send(self.hass, self.signal_reachable) - if not self.available: - self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) - else: - LOGGER.info("Connected to UniFi Network") + if not self.available: + self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) + else: + LOGGER.info("Connected to UniFi Network") @property def signal_reachable(self) -> str: @@ -259,7 +261,7 @@ class UniFiController: """Event specific per UniFi device tracker to signal new heartbeat missed.""" return "unifi-heartbeat-missed" - async def initialize(self): + async def initialize(self) -> None: """Set up a UniFi Network instance.""" await self.api.initialize() @@ -291,7 +293,7 @@ class UniFiController: continue client = self.api.clients_all[mac] - self.api.clients.process_raw([client.raw]) + self.api.clients.process_raw([dict(client.raw)]) LOGGER.debug( "Restore disconnected client %s (%s)", entry.entity_id, @@ -319,7 +321,7 @@ class UniFiController: del self._heartbeat_time[unique_id] @callback - def _async_check_for_stale(self, *_) -> None: + def _async_check_for_stale(self, *_: datetime) -> None: """Check for any devices scheduled to be marked disconnected.""" now = dt_util.utcnow() @@ -365,7 +367,7 @@ class UniFiController: async_dispatcher_send(hass, controller.signal_options_update) @callback - def reconnect(self, log=False) -> None: + def reconnect(self, log: bool = False) -> None: """Prepare to reconnect UniFi session.""" if log: LOGGER.info("Will try to reconnect to UniFi Network") @@ -387,14 +389,14 @@ class UniFiController: self.hass.loop.call_later(RETRY_TIMER, self.reconnect) @callback - def shutdown(self, event) -> None: + def shutdown(self, event: Event) -> None: """Wrap the call to unifi.close. Used as an argument to EventBus.async_listen_once. """ self.api.stop_websocket() - async def async_reset(self): + async def async_reset(self) -> bool: """Reset this controller to default state. Will cancel any scheduled setup retry and will unload @@ -421,15 +423,15 @@ async def get_unifi_controller( config: MappingProxyType[str, Any], ) -> aiounifi.Controller: """Create a controller object and verify authentication.""" - ssl_context = False + ssl_context: ssl.SSLContext | bool = False - if verify_ssl := bool(config.get(CONF_VERIFY_SSL)): + if verify_ssl := config.get(CONF_VERIFY_SSL): session = aiohttp_client.async_get_clientsession(hass) if isinstance(verify_ssl, str): ssl_context = ssl.create_default_context(cafile=verify_ssl) else: session = aiohttp_client.async_create_clientsession( - hass, verify_ssl=verify_ssl, cookie_jar=CookieJar(unsafe=True) + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) ) controller = aiounifi.Controller( diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index f31176afe3..149f865e77 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -19,7 +19,7 @@ from aiounifi.models.event import Event, EventKey from homeassistant.components.device_tracker import ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -268,7 +268,7 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): return self._attr_unique_id @callback - def _make_disconnected(self, *_) -> None: + def _make_disconnected(self, *_: core_Event) -> None: """No heart beat by device.""" self._is_connected = False self.async_write_ha_state() diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 7fde8a2ad7..473c4ed21a 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==45"], + "requirements": ["aiounifi==46"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index bd0166516d..87c9b9f4f4 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -247,8 +247,9 @@ async def async_setup_entry( for mac in controller.option_block_clients: if mac not in controller.api.clients and mac in controller.api.clients_all: - client = controller.api.clients_all[mac] - controller.api.clients.process_raw([client.raw]) + controller.api.clients.process_raw( + [dict(controller.api.clients_all[mac].raw)] + ) controller.register_platform_add_entities( UnifiSwitchEntity, ENTITY_DESCRIPTIONS, async_add_entities diff --git a/mypy.ini b/mypy.ini index 760c7f6811..b3a4cafba3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2873,7 +2873,7 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.unifi.update] +[mypy-homeassistant.components.unifi.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true diff --git a/requirements_all.txt b/requirements_all.txt index 66edf53480..daea2e6939 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -291,7 +291,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==45 +aiounifi==46 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf5998ed50..cde4c23f90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -272,7 +272,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==45 +aiounifi==46 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From f8431278c8556e71c984859ad6cbe35cd2b87a4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 23:05:21 -1000 Subject: [PATCH 0141/1165] Bump yalexs-ble to 2.1.6 (#90295) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 3db09bdc34..d30d3a39fb 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.5"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.6"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index ba314ecf2a..da9e0271e4 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.5"] + "requirements": ["yalexs-ble==2.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index daea2e6939..57e442108e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.5 +yalexs-ble==2.1.6 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cde4c23f90..4366ec257d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.5 +yalexs-ble==2.1.6 # homeassistant.components.august yalexs==1.2.7 From e0ec3488d3f4ea485e16124ebe1d4325bc9e716a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 14:20:05 +0200 Subject: [PATCH 0142/1165] Adjust IntFlag handling in syrupy (#90223) --- .core_files.yaml | 1 + .../components/elgato/snapshots/test_light.ambr | 6 +++--- tests/syrupy.py | 17 ++++++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.core_files.yaml b/.core_files.yaml index 7bf7a09b36..7933556b60 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -125,6 +125,7 @@ tests: &tests - tests/mock/** - tests/pylint/** - tests/scripts/** + - tests/syrupy.py - tests/test_util/** - tests/testing_config/** - tests/util/** diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 5395851673..31f5dfba21 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -23,7 +23,7 @@ 'supported_color_modes': list([ , ]), - 'supported_features': 0, + 'supported_features': , 'xy_color': tuple( 0.465, 0.376, @@ -130,7 +130,7 @@ , , ]), - 'supported_features': 0, + 'supported_features': , 'xy_color': tuple( 0.465, 0.376, @@ -236,7 +236,7 @@ , , ]), - 'supported_features': 0, + 'supported_features': , 'xy_color': tuple( 0.34, 0.327, diff --git a/tests/syrupy.py b/tests/syrupy.py index 4f646a05eb..f18c11bf5d 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -111,10 +111,10 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data = cls._serializable_config_entry(data) elif dataclasses.is_dataclass(data): serializable_data = dataclasses.asdict(data) - elif isinstance(data, IntFlag) and data == 0: + elif isinstance(data, IntFlag): # The repr of an enum.IntFlag has changed between Python 3.10 and 3.11 - # This only concerns the 0 case, which we normalize here - serializable_data = 0 + # so we normalize it here. + serializable_data = _IntFlagWrapper(data) else: serializable_data = data with suppress(TypeError): @@ -201,6 +201,17 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): ) +class _IntFlagWrapper: + def __init__(self, flag: IntFlag) -> None: + self._flag = flag + + def __repr__(self) -> str: + # 3.10: + # 3.11: + # Syrupy: + return f"<{self._flag.__class__.__name__}: {self._flag.value}>" + + class HomeAssistantSnapshotExtension(AmberSnapshotExtension): """Home Assistant extension for Syrupy.""" From 69a46d400253fad3d4457d5febd2c4e7042d16b6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 15:21:19 +0200 Subject: [PATCH 0143/1165] Adjust pylint plugin for components fixtures (#90217) * Adjust pylint plugin for components fixtures * Adjust components * Use MagicMock * Adjust * Use None --- pylint/plugins/hass_enforce_type_hints.py | 1 + tests/components/airzone/test_sensor.py | 4 +--- tests/components/conftest.py | 12 ++++++------ tests/components/goalzero/test_sensor.py | 3 +-- tests/components/gree/test_switch.py | 11 ++++++----- tests/components/homekit_controller/test_sensor.py | 4 ++-- tests/components/kostal_plenticore/test_number.py | 11 +++++------ tests/components/litterrobot/test_binary_sensor.py | 4 ++-- tests/components/oralb/test_sensor.py | 7 +++++-- tests/components/powerwall/test_sensor.py | 4 +++- tests/components/qnap_qsw/test_binary_sensor.py | 4 +--- tests/components/qnap_qsw/test_sensor.py | 4 +--- tests/components/radarr/test_sensor.py | 3 +-- tests/components/sensibo/test_binary_sensor.py | 4 ++-- tests/components/sensibo/test_climate.py | 12 ++++++------ tests/components/sensibo/test_number.py | 6 +++--- tests/components/sensibo/test_sensor.py | 4 ++-- tests/components/sonarr/test_sensor.py | 4 ++-- tests/components/switchbot/test_sensor.py | 5 ++++- tests/components/unifi/test_sensor.py | 4 ++-- tests/components/unifiprotect/test_sensor.py | 7 +++---- 21 files changed, 59 insertions(+), 59 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index f25b8db84a..ba0a511c57 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -104,6 +104,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "enable_statistics": "bool", "enable_schema_validation": "bool", "entity_registry": "EntityRegistry", + "entity_registry_enabled_by_default": "None", "freezer": "FrozenDateTimeFactory", "hass_access_token": "str", "hass_admin_credential": "Credentials", diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 151ee7c42f..1e7d335a46 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -1,14 +1,12 @@ """The sensor tests for the Airzone platform.""" -from unittest.mock import AsyncMock - from homeassistant.core import HomeAssistant from .util import async_init_integration async def test_airzone_create_sensors( - hass: HomeAssistant, entity_registry_enabled_by_default: AsyncMock + hass: HomeAssistant, entity_registry_enabled_by_default: None ) -> None: """Test creation of sensors.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 6cad53aea7..d57ef9768a 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -1,12 +1,12 @@ """Fixtures for component testing.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @pytest.fixture(scope="session", autouse=True) -def patch_zeroconf_multiple_catcher(): +def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: """Patch zeroconf wrapper that detects if multiple instances are used.""" with patch( "homeassistant.components.zeroconf.install_multiple_zeroconf_catcher", @@ -16,7 +16,7 @@ def patch_zeroconf_multiple_catcher(): @pytest.fixture(autouse=True) -def prevent_io(): +def prevent_io() -> Generator[None, None, None]: """Fixture to prevent certain I/O from happening.""" with patch( "homeassistant.components.http.ban.load_yaml_config_file", @@ -25,10 +25,10 @@ def prevent_io(): @pytest.fixture -def entity_registry_enabled_by_default() -> Generator[AsyncMock, None, None]: +def entity_registry_enabled_by_default() -> Generator[None, None, None]: """Test fixture that ensures all entities are enabled in the registry.""" with patch( "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", return_value=True, - ) as mock_entity_registry_enabled_by_default: - yield mock_entity_registry_enabled_by_default + ): + yield diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py index 5c979b7d84..47fbb29915 100644 --- a/tests/components/goalzero/test_sensor.py +++ b/tests/components/goalzero/test_sensor.py @@ -1,5 +1,4 @@ """Sensor tests for the Goalzero integration.""" -from unittest.mock import AsyncMock from homeassistant.components.goalzero.const import DEFAULT_NAME from homeassistant.components.sensor import ( @@ -29,7 +28,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test we get sensor data.""" await async_init_integration(hass, aioclient_mock) diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index 85b9a41caf..58c740b859 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -1,4 +1,5 @@ """Tests for gree component.""" + from greeclimate.exceptions import DeviceTimeoutError import pytest @@ -54,7 +55,7 @@ async def test_health_mode_disabled_by_default(hass): ], ) async def test_send_switch_on( - hass: HomeAssistant, entity, entity_registry_enabled_by_default + hass: HomeAssistant, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -82,7 +83,7 @@ async def test_send_switch_on( ], ) async def test_send_switch_on_device_timeout( - hass: HomeAssistant, device, entity, entity_registry_enabled_by_default + hass: HomeAssistant, device, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device with a device timeout.""" device().push_state_update.side_effect = DeviceTimeoutError @@ -112,7 +113,7 @@ async def test_send_switch_on_device_timeout( ], ) async def test_send_switch_off( - hass: HomeAssistant, entity, entity_registry_enabled_by_default + hass: HomeAssistant, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -140,7 +141,7 @@ async def test_send_switch_off( ], ) async def test_send_switch_toggle( - hass: HomeAssistant, entity, entity_registry_enabled_by_default + hass: HomeAssistant, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -193,7 +194,7 @@ async def test_send_switch_toggle( ], ) async def test_entity_name( - hass: HomeAssistant, entity, name, entity_registry_enabled_by_default + hass: HomeAssistant, entity, name, entity_registry_enabled_by_default: None ) -> None: """Test for name property.""" await async_setup_gree(hass) diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index c801ab91f7..6c9ad00870 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -364,7 +364,7 @@ def test_thread_status_to_str() -> None: async def test_rssi_sensor( hass: HomeAssistant, utcnow, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: """Test an rssi sensor.""" @@ -389,7 +389,7 @@ async def test_rssi_sensor( async def test_migrate_rssi_sensor_unique_id( hass: HomeAssistant, utcnow, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: """Test an rssi sensor unique id migration.""" diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index a1eb778a1c..beabd8fe66 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -1,5 +1,4 @@ """Test Kostal Plenticore number.""" - from collections.abc import Generator from datetime import timedelta from unittest.mock import patch @@ -90,7 +89,7 @@ async def test_setup_all_entries( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if all available entries are setup.""" @@ -109,7 +108,7 @@ async def test_setup_no_entries( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test that no entries are setup if Plenticore does not provide data.""" @@ -130,7 +129,7 @@ async def test_number_has_value( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if number has a value if data is provided on update.""" @@ -155,7 +154,7 @@ async def test_number_is_unavailable( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if number is unavailable if no data is provided on update.""" @@ -176,7 +175,7 @@ async def test_set_value( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if a new value could be set.""" diff --git a/tests/components/litterrobot/test_binary_sensor.py b/tests/components/litterrobot/test_binary_sensor.py index cbcdd44776..c6cfbff907 100644 --- a/tests/components/litterrobot/test_binary_sensor.py +++ b/tests/components/litterrobot/test_binary_sensor.py @@ -1,5 +1,5 @@ """Test the Litter-Robot binary sensor entity.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest @@ -17,7 +17,7 @@ from .conftest import setup_integration async def test_binary_sensors( hass: HomeAssistant, mock_account: MagicMock, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Tests binary sensors.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index d077230140..8c7bacce23 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -1,4 +1,5 @@ """Test the OralB sensors.""" + from homeassistant.components.oralb.const import DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant @@ -16,7 +17,9 @@ from tests.components.bluetooth import ( ) -async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) -> None: +async def test_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( domain=DOMAIN, @@ -47,7 +50,7 @@ async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) async def test_sensors_io_series_4( - hass: HomeAssistant, entity_registry_enabled_by_default + hass: HomeAssistant, entity_registry_enabled_by_default: None ) -> None: """Test setting up creates the sensors with an io series 4.""" entry = MockConfigEntry( diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index c72a3ff6fe..a0d4d7f9e9 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -20,7 +20,9 @@ from .mocks import _mock_powerwall_with_fixtures from tests.common import MockConfigEntry -async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) -> None: +async def test_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test creation of the sensors.""" mock_powerwall = await _mock_powerwall_with_fixtures(hass) diff --git a/tests/components/qnap_qsw/test_binary_sensor.py b/tests/components/qnap_qsw/test_binary_sensor.py index f007f79934..47eb6a00ba 100644 --- a/tests/components/qnap_qsw/test_binary_sensor.py +++ b/tests/components/qnap_qsw/test_binary_sensor.py @@ -1,7 +1,5 @@ """The binary sensor tests for the QNAP QSW platform.""" -from unittest.mock import AsyncMock - from homeassistant.components.qnap_qsw.const import ATTR_MESSAGE from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -12,7 +10,7 @@ from .util import async_init_integration async def test_qnap_qsw_create_binary_sensors( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, entity_registry: er.EntityRegistry, ) -> None: """Test creation of binary sensors.""" diff --git a/tests/components/qnap_qsw/test_sensor.py b/tests/components/qnap_qsw/test_sensor.py index 902f65d925..673a607acd 100644 --- a/tests/components/qnap_qsw/test_sensor.py +++ b/tests/components/qnap_qsw/test_sensor.py @@ -1,7 +1,5 @@ """The sensor tests for the QNAP QSW platform.""" -from unittest.mock import AsyncMock - from homeassistant.components.qnap_qsw.const import ATTR_MAX from homeassistant.core import HomeAssistant @@ -10,7 +8,7 @@ from .util import async_init_integration async def test_qnap_qsw_create_sensors( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test creation of sensors.""" diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 5c4ae3ea34..d3dde74dcb 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -1,5 +1,4 @@ """The tests for Radarr sensor platform.""" -from unittest.mock import AsyncMock from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT @@ -13,7 +12,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test for successfully setting up the Radarr platform.""" await setup_integration(hass, aioclient_mock) diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index 78d643eb74..d99dd2e871 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed async def test_binary_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 268abc5f89..be5b539fa0 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -720,7 +720,7 @@ async def test_climate_no_fan_no_swing( async def test_climate_set_timer( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -824,7 +824,7 @@ async def test_climate_set_timer( async def test_climate_pure_boost( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -928,7 +928,7 @@ async def test_climate_pure_boost( async def test_climate_climate_react( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -1091,7 +1091,7 @@ async def test_climate_climate_react( async def test_climate_climate_react_fahrenheit( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -1234,7 +1234,7 @@ async def test_climate_climate_react_fahrenheit( async def test_climate_full_ac_state( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sensibo/test_number.py b/tests/components/sensibo/test_number.py index c7a6a18616..1f96835596 100644 --- a/tests/components/sensibo/test_number.py +++ b/tests/components/sensibo/test_number.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -23,7 +23,7 @@ from tests.common import async_fire_time_changed async def test_number( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -53,7 +53,7 @@ async def test_number( async def test_number_set_value( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, get_data: SensiboData, ) -> None: diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index f84c7cf900..8d3f705215 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed async def test_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index c00236c54e..9f27e59365 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -1,6 +1,6 @@ """Tests for the Sonarr sensor platform.""" from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch from aiopyarr import ArrException import pytest @@ -25,7 +25,7 @@ async def test_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_sonarr: MagicMock, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test the creation and values of the sensors.""" registry = er.async_get(hass) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index e801faf257..80c85cdb60 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -1,4 +1,5 @@ """Test the switchbot sensors.""" + from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.switchbot.const import DOMAIN from homeassistant.const import ( @@ -18,7 +19,9 @@ from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) -> None: +async def test_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test setting up creates the sensors.""" await async_setup_component(hass, DOMAIN, {}) inject_bluetooth_service_info(hass, WOHAND_SERVICE_INFO) diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index b4b8216626..bf7ba4d53c 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -210,7 +210,7 @@ async def test_uptime_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, initial_uptime, event_uptime, new_uptime, @@ -296,7 +296,7 @@ async def test_remove_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Verify removing of clients work as expected.""" wired_client = { diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 0d763e6f90..db7cdc801b 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -1,9 +1,8 @@ """Test the UniFi Protect sensor platform.""" - from __future__ import annotations from datetime import datetime, timedelta -from unittest.mock import AsyncMock, Mock +from unittest.mock import Mock from pyunifiprotect.data import ( NVR, @@ -398,7 +397,7 @@ async def test_sensor_setup_camera( async def test_sensor_setup_camera_with_last_trip_time( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime, @@ -474,7 +473,7 @@ async def test_sensor_update_alarm( async def test_sensor_update_alarm_with_last_trip_time( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ufp: MockUFPFixture, sensor_all: Sensor, fixed_now: datetime, From bec7bbeb9221e9f7c8c0e551ba6dacd6d41e1d97 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sun, 26 Mar 2023 16:57:19 +0200 Subject: [PATCH 0144/1165] Use SnapshotAssertion in bmw_connected_drive tests (#90128) --- .../diagnostics/diagnostics_config_entry.json | 803 ------ .../diagnostics/diagnostics_device.json | 801 ------ .../snapshots/test_diagnostics.ambr | 2373 +++++++++++++++++ .../bmw_connected_drive/test_diagnostics.py | 38 +- 4 files changed, 2389 insertions(+), 1626 deletions(-) delete mode 100644 tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json delete mode 100644 tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json create mode 100644 tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json deleted file mode 100644 index 12e85bb852..0000000000 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json +++ /dev/null @@ -1,803 +0,0 @@ -{ - "info": { - "username": "**REDACTED**", - "password": "**REDACTED**", - "region": "rest_of_world", - "refresh_token": "**REDACTED**" - }, - "data": [ - { - "data": { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**", - "charging_settings": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - }, - "is_metric": true, - "fetched_at": "2022-07-10T11:00:00+00:00", - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - }, - "fuel_and_battery": { - "remaining_range_fuel": [105, "km"], - "remaining_range_electric": [174, "km"], - "remaining_range_total": [279, "km"], - "remaining_fuel": [6, "L"], - "remaining_fuel_percent": 65, - "remaining_battery_percent": 82, - "charging_status": "WAITING_FOR_CHARGING", - "charging_start_time_no_tz": "2022-07-10T18:01:00", - "charging_end_time": null, - "is_charger_connected": true, - "charging_target": 100, - "account_timezone": { - "_std_offset": "0:00:00", - "_dst_offset": "0:00:00", - "_dst_saved": "0:00:00", - "_hasdst": false, - "_tznames": ["UTC", "UTC"] - }, - "charging_start_time": "2022-07-10T18:01:00+00:00" - }, - "vehicle_location": { - "location": null, - "heading": null, - "vehicle_update_timestamp": "2022-07-10T09:25:53+00:00", - "account_region": "row", - "remote_service_position": null - }, - "doors_and_windows": { - "door_lock_state": "UNLOCKED", - "lids": [ - { "name": "hood", "state": "CLOSED", "is_closed": true }, - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "leftRear", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightRear", "state": "CLOSED", "is_closed": true }, - { "name": "trunk", "state": "CLOSED", "is_closed": true }, - { "name": "sunRoof", "state": "CLOSED", "is_closed": true } - ], - "windows": [ - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true } - ], - "all_lids_closed": true, - "all_windows_closed": true, - "open_lids": [], - "open_windows": [] - }, - "condition_based_services": { - "messages": [ - { - "service_type": "BRAKE_FLUID", - "state": "OK", - "due_date": "2022-10-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_CHECK", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_TUV", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - } - ], - "is_service_required": false - }, - "check_control_messages": { - "messages": [], - "has_check_control_messages": false - }, - "charging_profile": { - "is_pre_entry_climatization_enabled": false, - "timer_type": "WEEKLY_PLANNER", - "departure_times": [ - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "07:35:00", - "timer_id": 1, - "weekdays": ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "18:00:00", - "timer_id": 2, - "weekdays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": "07:00:00", - "timer_id": 3, - "weekdays": [] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 4, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": null, - "timer_id": 4, - "weekdays": [] - } - ], - "preferred_charging_window": { - "_window_dict": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - }, - "end_time": "01:30:00", - "start_time": "18:01:00" - }, - "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING", - "ac_current_limit": null, - "ac_available_limits": null, - "charging_preferences_service_pack": "TCB1" - }, - "available_attributes": [ - "gps_position", - "vin", - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent", - "condition_based_services", - "check_control_messages", - "door_lock_state", - "timestamp", - "lids", - "windows" - ], - "brand": "bmw", - "drive_train": "ELECTRIC_WITH_RANGE_EXTENDER", - "drive_train_attributes": [ - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent" - ], - "has_combustion_drivetrain": true, - "has_electric_drivetrain": true, - "is_charging_plan_supported": true, - "is_lsc_enabled": true, - "is_remote_charge_start_enabled": false, - "is_remote_charge_stop_enabled": false, - "is_remote_climate_start_enabled": true, - "is_remote_climate_stop_enabled": false, - "is_remote_horn_enabled": true, - "is_remote_lights_enabled": true, - "is_remote_lock_enabled": true, - "is_remote_sendpoi_enabled": true, - "is_remote_set_ac_limit_enabled": false, - "is_remote_set_target_soc_enabled": false, - "is_remote_unlock_enabled": true, - "is_vehicle_active": false, - "is_vehicle_tracking_enabled": false, - "lsc_type": "ACTIVATED", - "mileage": [137009, "km"], - "name": "i3 (+ REX)", - "timestamp": "2022-07-10T09:25:53+00:00", - "vin": "**REDACTED**" - } - ], - "fingerprint": [ - { - "filename": "bmw-eadrax-vcs_v4_vehicles.json", - "content": [ - { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**" - } - ] - }, - { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, - { - "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", - "content": { - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - } - }, - { - "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", - "content": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - } - } - ] -} diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json deleted file mode 100644 index 8e1fe5019c..0000000000 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json +++ /dev/null @@ -1,801 +0,0 @@ -{ - "info": { - "username": "**REDACTED**", - "password": "**REDACTED**", - "region": "rest_of_world", - "refresh_token": "**REDACTED**" - }, - "data": { - "data": { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**", - "charging_settings": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - }, - "is_metric": true, - "fetched_at": "2022-07-10T11:00:00+00:00", - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - }, - "fuel_and_battery": { - "remaining_range_fuel": [105, "km"], - "remaining_range_electric": [174, "km"], - "remaining_range_total": [279, "km"], - "remaining_fuel": [6, "L"], - "remaining_fuel_percent": 65, - "remaining_battery_percent": 82, - "charging_status": "WAITING_FOR_CHARGING", - "charging_start_time_no_tz": "2022-07-10T18:01:00", - "charging_end_time": null, - "is_charger_connected": true, - "charging_target": 100, - "account_timezone": { - "_std_offset": "0:00:00", - "_dst_offset": "0:00:00", - "_dst_saved": "0:00:00", - "_hasdst": false, - "_tznames": ["UTC", "UTC"] - }, - "charging_start_time": "2022-07-10T18:01:00+00:00" - }, - "vehicle_location": { - "location": null, - "heading": null, - "vehicle_update_timestamp": "2022-07-10T09:25:53+00:00", - "account_region": "row", - "remote_service_position": null - }, - "doors_and_windows": { - "door_lock_state": "UNLOCKED", - "lids": [ - { "name": "hood", "state": "CLOSED", "is_closed": true }, - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "leftRear", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightRear", "state": "CLOSED", "is_closed": true }, - { "name": "trunk", "state": "CLOSED", "is_closed": true }, - { "name": "sunRoof", "state": "CLOSED", "is_closed": true } - ], - "windows": [ - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true } - ], - "all_lids_closed": true, - "all_windows_closed": true, - "open_lids": [], - "open_windows": [] - }, - "condition_based_services": { - "messages": [ - { - "service_type": "BRAKE_FLUID", - "state": "OK", - "due_date": "2022-10-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_CHECK", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_TUV", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - } - ], - "is_service_required": false - }, - "check_control_messages": { - "messages": [], - "has_check_control_messages": false - }, - "charging_profile": { - "is_pre_entry_climatization_enabled": false, - "timer_type": "WEEKLY_PLANNER", - "departure_times": [ - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "07:35:00", - "timer_id": 1, - "weekdays": ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "18:00:00", - "timer_id": 2, - "weekdays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": "07:00:00", - "timer_id": 3, - "weekdays": [] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 4, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": null, - "timer_id": 4, - "weekdays": [] - } - ], - "preferred_charging_window": { - "_window_dict": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - }, - "end_time": "01:30:00", - "start_time": "18:01:00" - }, - "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING", - "ac_current_limit": null, - "ac_available_limits": null, - "charging_preferences_service_pack": "TCB1" - }, - "available_attributes": [ - "gps_position", - "vin", - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent", - "condition_based_services", - "check_control_messages", - "door_lock_state", - "timestamp", - "lids", - "windows" - ], - "brand": "bmw", - "drive_train": "ELECTRIC_WITH_RANGE_EXTENDER", - "drive_train_attributes": [ - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent" - ], - "has_combustion_drivetrain": true, - "has_electric_drivetrain": true, - "is_charging_plan_supported": true, - "is_lsc_enabled": true, - "is_remote_charge_start_enabled": false, - "is_remote_charge_stop_enabled": false, - "is_remote_climate_start_enabled": true, - "is_remote_climate_stop_enabled": false, - "is_remote_horn_enabled": true, - "is_remote_lights_enabled": true, - "is_remote_lock_enabled": true, - "is_remote_sendpoi_enabled": true, - "is_remote_set_ac_limit_enabled": false, - "is_remote_set_target_soc_enabled": false, - "is_remote_unlock_enabled": true, - "is_vehicle_active": false, - "is_vehicle_tracking_enabled": false, - "lsc_type": "ACTIVATED", - "mileage": [137009, "km"], - "name": "i3 (+ REX)", - "timestamp": "2022-07-10T09:25:53+00:00", - "vin": "**REDACTED**" - }, - "fingerprint": [ - { - "filename": "bmw-eadrax-vcs_v4_vehicles.json", - "content": [ - { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**" - } - ] - }, - { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, - { - "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", - "content": { - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - } - }, - { - "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", - "content": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - } - } - ] -} diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000..349706f593 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -0,0 +1,2373 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'data': list([ + dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': None, + 'ac_current_limit': None, + 'charging_mode': 'DELAYED_CHARGING', + 'charging_preferences': 'CHARGING_WINDOW', + 'charging_preferences_service_pack': 'TCB1', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:35:00', + 'timer_id': 1, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '18:00:00', + 'timer_id': 2, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': None, + 'timer_id': 4, + 'weekdays': list([ + ]), + }), + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + 'end_time': '01:30:00', + 'start_time': '18:01:00', + }), + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + ]), + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2022-10-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'UNLOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'sunRoof', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': None, + 'charging_start_time': '2022-07-10T18:01:00+00:00', + 'charging_start_time_no_tz': '2022-07-10T18:01:00', + 'charging_status': 'WAITING_FOR_CHARGING', + 'charging_target': 100, + 'is_charger_connected': True, + 'remaining_battery_percent': 82, + 'remaining_fuel': list([ + 6, + 'L', + ]), + 'remaining_fuel_percent': 65, + 'remaining_range_electric': list([ + 174, + 'km', + ]), + 'remaining_range_fuel': list([ + 105, + 'km', + ]), + 'remaining_range_total': list([ + 279, + 'km', + ]), + }), + 'has_combustion_drivetrain': True, + 'has_electric_drivetrain': True, + 'is_charging_plan_supported': True, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': False, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': False, + 'is_remote_set_target_soc_enabled': False, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': False, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 137009, + 'km', + ]), + 'name': 'i3 (+ REX)', + 'timestamp': '2022-07-10T09:25:53+00:00', + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': None, + 'location': None, + 'remote_service_position': None, + 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + }), + 'vin': '**REDACTED**', + }), + ]), + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- +# name: test_device_diagnostics + dict({ + 'data': dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': None, + 'ac_current_limit': None, + 'charging_mode': 'DELAYED_CHARGING', + 'charging_preferences': 'CHARGING_WINDOW', + 'charging_preferences_service_pack': 'TCB1', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:35:00', + 'timer_id': 1, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '18:00:00', + 'timer_id': 2, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': None, + 'timer_id': 4, + 'weekdays': list([ + ]), + }), + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + 'end_time': '01:30:00', + 'start_time': '18:01:00', + }), + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + ]), + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2022-10-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'UNLOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'sunRoof', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': None, + 'charging_start_time': '2022-07-10T18:01:00+00:00', + 'charging_start_time_no_tz': '2022-07-10T18:01:00', + 'charging_status': 'WAITING_FOR_CHARGING', + 'charging_target': 100, + 'is_charger_connected': True, + 'remaining_battery_percent': 82, + 'remaining_fuel': list([ + 6, + 'L', + ]), + 'remaining_fuel_percent': 65, + 'remaining_range_electric': list([ + 174, + 'km', + ]), + 'remaining_range_fuel': list([ + 105, + 'km', + ]), + 'remaining_range_total': list([ + 279, + 'km', + ]), + }), + 'has_combustion_drivetrain': True, + 'has_electric_drivetrain': True, + 'is_charging_plan_supported': True, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': False, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': False, + 'is_remote_set_target_soc_enabled': False, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': False, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 137009, + 'km', + ]), + 'name': 'i3 (+ REX)', + 'timestamp': '2022-07-10T09:25:53+00:00', + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': None, + 'location': None, + 'remote_service_position': None, + 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + }), + 'vin': '**REDACTED**', + }), + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- +# name: test_device_diagnostics_vehicle_not_found + dict({ + 'data': None, + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/bmw_connected_drive/test_diagnostics.py b/tests/components/bmw_connected_drive/test_diagnostics.py index a186a52bcd..0509409ad0 100644 --- a/tests/components/bmw_connected_drive/test_diagnostics.py +++ b/tests/components/bmw_connected_drive/test_diagnostics.py @@ -1,10 +1,10 @@ """Test BMW diagnostics.""" import datetime -import json import os import time import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.bmw_connected_drive.const import DOMAIN from homeassistant.core import HomeAssistant @@ -12,7 +12,6 @@ from homeassistant.helpers import device_registry as dr from . import setup_mocked_integration -from tests.common import load_fixture from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -22,7 +21,10 @@ from tests.typing import ClientSessionGenerator @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_config_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bmw_fixture, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" @@ -36,16 +38,15 @@ async def test_config_entry_diagnostics( hass, hass_client, mock_config_entry ) - diagnostics_fixture = json.loads( - load_fixture("diagnostics/diagnostics_config_entry.json", DOMAIN) - ) - - assert diagnostics == diagnostics_fixture + assert diagnostics == snapshot @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bmw_fixture, + snapshot: SnapshotAssertion, ) -> None: """Test device diagnostics.""" @@ -65,16 +66,15 @@ async def test_device_diagnostics( hass, hass_client, mock_config_entry, reg_device ) - diagnostics_fixture = json.loads( - load_fixture("diagnostics/diagnostics_device.json", DOMAIN) - ) - - assert diagnostics == diagnostics_fixture + assert diagnostics == snapshot @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics_vehicle_not_found( - hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bmw_fixture, + snapshot: SnapshotAssertion, ) -> None: """Test device diagnostics when the vehicle cannot be found.""" @@ -99,10 +99,4 @@ async def test_device_diagnostics_vehicle_not_found( hass, hass_client, mock_config_entry, reg_device ) - diagnostics_fixture = json.loads( - load_fixture("diagnostics/diagnostics_device.json", DOMAIN) - ) - # Mock empty data if car is not found in account anymore - diagnostics_fixture["data"] = None - - assert diagnostics == diagnostics_fixture + assert diagnostics == snapshot From 6e92dac61ff095bcbb541a1639007c66d70b1f6b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 18:37:26 +0200 Subject: [PATCH 0145/1165] Adjust pylint plugin for return type inheritance (#90046) --- pylint/plugins/hass_enforce_type_hints.py | 13 +++---------- tests/pylint/test_enforce_type_hints.py | 6 ++++++ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index ba0a511c57..8dbb041fa9 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -42,7 +42,6 @@ class TypeHintMatch: """named_arg_types is for named or keyword arguments""" kwargs_type: str | None = None """kwargs_type is for the special case `**kwargs`""" - check_return_type_inheritance: bool = False has_async_counterpart: bool = False def need_to_check_function(self, node: nodes.FunctionDef) -> bool: @@ -398,7 +397,6 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type=["DeviceScanner", None], - check_return_type_inheritance=True, has_async_counterpart=True, ), ], @@ -466,7 +464,6 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 2: "DiscoveryInfoType | None", }, return_type=["BaseNotificationService", None], - check_return_type_inheritance=True, has_async_counterpart=True, ), ], @@ -493,7 +490,6 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 0: "ConfigEntry", }, return_type="OptionsFlow", - check_return_type_inheritance=True, ), TypeHintMatch( function_name="async_step_dhcp", @@ -681,7 +677,6 @@ _RESTORE_ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( function_name="extra_restore_state_data", return_type=["ExtraStoredData", None], - check_return_type_inheritance=True, ), ] _TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ @@ -2842,15 +2837,13 @@ def _is_valid_return_type(match: TypeHintMatch, node: nodes.NodeNG) -> bool: match, node.right ) - if ( - match.check_return_type_inheritance - and isinstance(match.return_type, (str, list)) - and isinstance(node, nodes.Name) - ): + if isinstance(match.return_type, (str, list)) and isinstance(node, nodes.Name): if isinstance(match.return_type, str): valid_types = {match.return_type} else: valid_types = {el for el in match.return_type if isinstance(el, str)} + if "Mapping[str, Any]" in valid_types: + valid_types.add("TypedDict") try: for infer_node in node.infer(): diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 365ccc111d..c580658b54 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -724,6 +724,7 @@ def test_invalid_mapping_return_type( "-> Mapping[str, bool | int]", "-> dict[str, Any]", "-> dict[str, str]", + "-> CustomTypedDict", ], ) def test_valid_mapping_return_type( @@ -737,6 +738,11 @@ def test_valid_mapping_return_type( class_node = astroid.extract_node( f""" + from typing import TypedDict + + class CustomTypedDict(TypedDict): + pass + class Entity(): pass From 1baadc1d09ed9e856dc6083545da805c0b5dba15 Mon Sep 17 00:00:00 2001 From: Niels Perfors Date: Sun, 26 Mar 2023 19:32:25 +0200 Subject: [PATCH 0146/1165] Update Verisure package to 2.6.1 (#89318) Co-authored-by: Franck Nijhof Co-authored-by: RobinBolder <33325401+RobinBolder@users.noreply.github.com> Co-authored-by: Tobias Lindaaker --- CODEOWNERS | 4 +- homeassistant/components/verisure/__init__.py | 10 +- .../verisure/alarm_control_panel.py | 34 +++-- .../components/verisure/binary_sensor.py | 4 +- homeassistant/components/verisure/camera.py | 19 ++- .../components/verisure/config_flow.py | 30 ++-- homeassistant/components/verisure/const.py | 3 + .../components/verisure/coordinator.py | 140 +++++++++++++----- .../components/verisure/diagnostics.py | 1 + homeassistant/components/verisure/lock.py | 54 ++++--- .../components/verisure/manifest.json | 4 +- homeassistant/components/verisure/sensor.py | 80 ++-------- homeassistant/components/verisure/switch.py | 33 +++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/verisure/conftest.py | 22 ++- tests/components/verisure/test_config_flow.py | 71 ++++----- 17 files changed, 294 insertions(+), 219 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 617fc46c27..ff31997ce6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1293,8 +1293,8 @@ build.json @home-assistant/supervisor /homeassistant/components/velux/ @Julius2342 /homeassistant/components/venstar/ @garbled1 /tests/components/venstar/ @garbled1 -/homeassistant/components/verisure/ @frenck -/tests/components/verisure/ @frenck +/homeassistant/components/verisure/ @frenck @niro1987 +/tests/components/verisure/ @frenck @niro1987 /homeassistant/components/versasense/ @flamm3blemuff1n /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index 9ad8db08d5..94e8d667d7 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -6,9 +6,9 @@ import os from pathlib import Path from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_EMAIL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR @@ -34,11 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = VerisureDataUpdateCoordinator(hass, entry=entry) if not await coordinator.async_login(): - raise ConfigEntryAuthFailed - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.async_logout) - ) + raise ConfigEntryNotReady("Could not log in to verisure.") await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 5030e01c8b..0cfd6ebb81 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -55,33 +55,49 @@ class VerisureAlarm( """Return the unique ID for this entity.""" return self.coordinator.entry.data[CONF_GIID] - async def _async_set_arm_state(self, state: str, code: str | None = None) -> None: + async def _async_set_arm_state( + self, state: str, command_data: dict[str, str | dict[str, str]] + ) -> None: """Send set arm state command.""" arm_state = await self.hass.async_add_executor_job( - self.coordinator.verisure.set_arm_state, code, state + self.coordinator.verisure.request, command_data ) LOGGER.debug("Verisure set arm state %s", state) - transaction = {} - while "result" not in transaction: + result = None + while result is None: await asyncio.sleep(0.5) transaction = await self.hass.async_add_executor_job( - self.coordinator.verisure.get_arm_state_transaction, - arm_state["armStateChangeTransactionId"], + self.coordinator.verisure.request, + self.coordinator.verisure.poll_arm_state( + list(arm_state["data"].values())[0], state + ), + ) + result = ( + transaction.get("data", {}) + .get("installation", {}) + .get("armStateChangePollResult", {}) + .get("result") ) await self.coordinator.async_refresh() async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - await self._async_set_arm_state("DISARMED", code) + await self._async_set_arm_state( + "DISARMED", self.coordinator.verisure.disarm(code) + ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._async_set_arm_state("ARMED_HOME", code) + await self._async_set_arm_state( + "ARMED_HOME", self.coordinator.verisure.arm_home(code) + ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._async_set_arm_state("ARMED_AWAY", code) + await self._async_set_arm_state( + "ARMED_AWAY", self.coordinator.verisure.arm_away(code) + ) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 8283480a14..536b96ea2c 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -109,9 +109,9 @@ class VerisureEthernetStatus( @property def is_on(self) -> bool: """Return the state of the sensor.""" - return self.coordinator.data["ethernet"] + return self.coordinator.data["broadband"]["isBroadbandConnected"] @property def available(self) -> bool: """Return True if entity is available.""" - return super().available and self.coordinator.data["ethernet"] is not None + return super().available and self.coordinator.data["broadband"] is not None diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 98ed41c5b9..1f890a22a6 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -63,12 +63,12 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) self.serial_number = serial_number self._directory_path = directory_path self._image: str | None = None - self._image_id = None + self._image_id: str | None = None @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["cameras"][self.serial_number]["area"] + area = self.coordinator.data["cameras"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -95,16 +95,16 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) """Check the contents of the image list.""" self.coordinator.update_smartcam_imageseries() - images = self.coordinator.imageseries.get("imageSeries", []) - new_image_id = None - for image in images: + new_image = None + for image in self.coordinator.imageseries: if image["deviceLabel"] == self.serial_number: - new_image_id = image["image"][0]["imageId"] + new_image = image break - if not new_image_id: + if not new_image: return + new_image_id = new_image["mediaId"] if new_image_id in ("-1", self._image_id): LOGGER.debug("The image is the same, or loading image_id") return @@ -113,9 +113,8 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) new_image_path = os.path.join( self._directory_path, "{}{}".format(new_image_id, ".jpg") ) - self.coordinator.verisure.download_image( - self.serial_number, new_image_id, new_image_path - ) + new_image_url = new_image["contentUrl"] + self.coordinator.verisure.download_image(new_image_url, new_image_path) LOGGER.debug("Old image_id=%s", self._image_id) self.delete_image() diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index d53c7c9ed6..9392cdd9bc 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -56,7 +56,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.verisure = Verisure( username=self.email, password=self.password, - cookieFileName=self.hass.config.path( + cookie_file_name=self.hass.config.path( STORAGE_DIR, f"verisure_{user_input[CONF_EMAIL]}" ), ) @@ -66,7 +66,9 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): except VerisureLoginError as ex: if "Multifactor authentication enabled" in str(ex): try: - await self.hass.async_add_executor_job(self.verisure.login_mfa) + await self.hass.async_add_executor_job( + self.verisure.request_mfa + ) except ( VerisureLoginError, VerisureError, @@ -108,9 +110,8 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await self.hass.async_add_executor_job( - self.verisure.mfa_validate, user_input[CONF_CODE], True + self.verisure.validate_mfa, user_input[CONF_CODE] ) - await self.hass.async_add_executor_job(self.verisure.login) except VerisureLoginError as ex: LOGGER.debug("Could not log in to Verisure, %s", ex) errors["base"] = "invalid_auth" @@ -136,9 +137,16 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Select Verisure installation to add.""" + installations_data = await self.hass.async_add_executor_job( + self.verisure.get_installations + ) installations = { - inst["giid"]: f"{inst['alias']} ({inst['street']})" - for inst in self.verisure.installations or [] + inst["giid"]: f"{inst['alias']} ({inst['address']['street']})" + for inst in ( + installations_data.get("data", {}) + .get("account", {}) + .get("installations", []) + ) } if user_input is None: @@ -184,8 +192,8 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.verisure = Verisure( username=self.email, password=self.password, - cookieFileName=self.hass.config.path( - STORAGE_DIR, f"verisure-{user_input[CONF_EMAIL]}" + cookie_file_name=self.hass.config.path( + STORAGE_DIR, f"verisure_{user_input[CONF_EMAIL]}" ), ) @@ -194,7 +202,9 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): except VerisureLoginError as ex: if "Multifactor authentication enabled" in str(ex): try: - await self.hass.async_add_executor_job(self.verisure.login_mfa) + await self.hass.async_add_executor_job( + self.verisure.request_mfa + ) except ( VerisureLoginError, VerisureError, @@ -248,7 +258,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await self.hass.async_add_executor_job( - self.verisure.mfa_validate, user_input[CONF_CODE], True + self.verisure.validate_mfa, user_input[CONF_CODE] ) await self.hass.async_add_executor_job(self.verisure.login) except VerisureLoginError as ex: diff --git a/homeassistant/components/verisure/const.py b/homeassistant/components/verisure/const.py index e8720baa1d..ac30c58fde 100644 --- a/homeassistant/components/verisure/const.py +++ b/homeassistant/components/verisure/const.py @@ -36,6 +36,9 @@ DEVICE_TYPE_NAME = { "SMOKE3": "Smoke detector", "VOICEBOX1": "VoiceBox", "WATER1": "Water detector", + "SMOKE": "Smoke detector", + "SIREN": "Siren", + "VOICEBOX": "VoiceBox", } ALARM_STATE_TO_HA = { diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index 17cadb9598..47fbde3ef2 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -2,19 +2,21 @@ from __future__ import annotations from datetime import timedelta -from http import HTTPStatus +from time import sleep from verisure import ( Error as VerisureError, + LoginError as VerisureLoginError, ResponseError as VerisureResponseError, Session as Verisure, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.storage import STORAGE_DIR -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import Throttle from .const import CONF_GIID, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER @@ -25,13 +27,14 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the Verisure hub.""" - self.imageseries: dict[str, list] = {} + self.imageseries: list[dict[str, str]] = [] self.entry = entry + self._overview: list[dict] = [] self.verisure = Verisure( username=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], - cookieFileName=hass.config.path( + cookie_file_name=hass.config.path( STORAGE_DIR, f"verisure_{entry.data[CONF_EMAIL]}" ), ) @@ -43,8 +46,11 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): async def async_login(self) -> bool: """Login to Verisure.""" try: - await self.hass.async_add_executor_job(self.verisure.login) - except VerisureError as ex: + await self.hass.async_add_executor_job(self.verisure.login_cookie) + except VerisureLoginError as ex: + LOGGER.error("Could not log in to verisure, %s", ex) + raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex + except VerisureResponseError as ex: LOGGER.error("Could not log in to verisure, %s", ex) return False @@ -54,62 +60,116 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): return True - async def async_logout(self, _event: Event) -> None: - """Logout from Verisure.""" - try: - await self.hass.async_add_executor_job(self.verisure.logout) - except VerisureError as ex: - LOGGER.error("Could not log out from verisure, %s", ex) - async def _async_update_data(self) -> dict: """Fetch data from Verisure.""" try: - overview = await self.hass.async_add_executor_job( - self.verisure.get_overview - ) + await self.hass.async_add_executor_job(self.verisure.update_cookie) + except VerisureLoginError as ex: + LOGGER.error("Credentials expired for Verisure, %s", ex) + raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex except VerisureResponseError as ex: - LOGGER.error("Could not read overview, %s", ex) - if ex.status_code == HTTPStatus.SERVICE_UNAVAILABLE: - LOGGER.info("Trying to log in again") - await self.async_login() - return {} - raise + LOGGER.error("Could not log in to verisure, %s", ex) + raise ConfigEntryAuthFailed("Could not log in to verisure") from ex + try: + overview = await self.hass.async_add_executor_job( + self.verisure.request, + self.verisure.arm_state(), + self.verisure.broadband(), + self.verisure.cameras(), + self.verisure.climate(), + self.verisure.door_window(), + self.verisure.smart_lock(), + self.verisure.smartplugs(), + ) + except VerisureResponseError as err: + LOGGER.debug("Cookie expired or service unavailable, %s", err) + overview = self._overview + try: + await self.hass.async_add_executor_job(self.verisure.update_cookie) + except VerisureResponseError as ex: + raise ConfigEntryAuthFailed("Credentials for Verisure expired.") from ex + except VerisureError as err: + LOGGER.error("Could not read overview, %s", err) + raise UpdateFailed("Could not read overview") from err + + def unpack(overview: list, value: str) -> dict | list: + return next( + ( + item["data"]["installation"][value] + for item in overview + if value in item.get("data", {}).get("installation", {}) + ), + [], + ) # Store data in a way Home Assistant can easily consume it + self._overview = overview return { - "alarm": overview["armState"], - "ethernet": overview.get("ethernetConnectedNow"), + "alarm": unpack(overview, "armState"), + "broadband": unpack(overview, "broadband"), "cameras": { - device["deviceLabel"]: device - for device in overview["customerImageCameras"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "cameras") }, "climate": { - device["deviceLabel"]: device for device in overview["climateValues"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "climates") }, "door_window": { - device["deviceLabel"]: device - for device in overview["doorWindow"]["doorWindowDevice"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "doorWindows") }, "locks": { - device["deviceLabel"]: device - for device in overview["doorLockStatusList"] - }, - "mice": { - device["deviceLabel"]: device - for device in overview["eventCounts"] - if device["deviceType"] == "MOUSE1" + device["device"]["deviceLabel"]: device + for device in unpack(overview, "smartLocks") }, "smart_plugs": { - device["deviceLabel"]: device for device in overview["smartPlugs"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "smartplugs") }, } @Throttle(timedelta(seconds=60)) def update_smartcam_imageseries(self) -> None: """Update the image series.""" - self.imageseries = self.verisure.get_camera_imageseries() + image_data = self.verisure.request(self.verisure.cameras_image_series()) + self.imageseries = [ + content + for series in ( + image_data.get("data", {}) + .get("ContentProviderMediaSearch", {}) + .get("mediaSeriesList", []) + ) + for content in series.get("deviceMediaList", []) + if content.get("contentType") == "IMAGE_JPEG" + ] @Throttle(timedelta(seconds=30)) def smartcam_capture(self, device_id: str) -> None: """Capture a new image from a smartcam.""" - self.verisure.capture_image(device_id) + capture_request = self.verisure.request( + self.verisure.camera_get_request_id(device_id) + ) + request_id = ( + capture_request.get("data", {}) + .get("ContentProviderCaptureImageRequest", {}) + .get("requestId") + ) + capture_status = None + attempts = 0 + while capture_status != "AVAILABLE": + if attempts == 30: + break + if attempts > 1: + sleep(0.5) + attempts += 1 + capture_data = self.verisure.request( + self.verisure.camera_capture(device_id, request_id) + ) + capture_status = ( + capture_data.get("data", {}) + .get("installation", {}) + .get("cameraContentProvider", {}) + .get("captureImageRequestStatus", {}) + .get("mediaRequestStatus") + ) diff --git a/homeassistant/components/verisure/diagnostics.py b/homeassistant/components/verisure/diagnostics.py index 740aff0b90..8dbffe6eee 100644 --- a/homeassistant/components/verisure/diagnostics.py +++ b/homeassistant/components/verisure/diagnostics.py @@ -16,6 +16,7 @@ TO_REDACT = { "deviceArea", "name", "time", + "reportTime", "userString", } diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 02cdad158c..d13005b265 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -77,7 +77,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["locks"][self.serial_number]["area"] + area = self.coordinator.data["locks"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -98,12 +98,16 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def changed_by(self) -> str | None: """Last change triggered by.""" - return self.coordinator.data["locks"][self.serial_number].get("userString") + return ( + self.coordinator.data["locks"][self.serial_number] + .get("user", {}) + .get("name") + ) @property def changed_method(self) -> str: """Last change method.""" - return self.coordinator.data["locks"][self.serial_number]["method"] + return self.coordinator.data["locks"][self.serial_number]["lockMethod"] @property def code_format(self) -> str: @@ -114,8 +118,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt def is_locked(self) -> bool: """Return true if lock is locked.""" return ( - self.coordinator.data["locks"][self.serial_number]["lockedState"] - == "LOCKED" + self.coordinator.data["locks"][self.serial_number]["lockStatus"] == "LOCKED" ) @property @@ -147,28 +150,39 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt async def async_set_lock_state(self, code: str, state: str) -> None: """Send set lock state command.""" - target_state = "lock" if state == STATE_LOCKED else "unlock" - lock_state = await self.hass.async_add_executor_job( - self.coordinator.verisure.set_lock_state, - code, - self.serial_number, - target_state, + command = ( + self.coordinator.verisure.door_lock(self.serial_number, code) + if state == STATE_LOCKED + else self.coordinator.verisure.door_unlock(self.serial_number, code) + ) + lock_request = await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + command, ) - LOGGER.debug("Verisure doorlock %s", state) - transaction = {} + transaction_id = lock_request.get("data", {}).get(command["operationName"]) + target_state = "LOCKED" if state == STATE_LOCKED else "UNLOCKED" + lock_status = None attempts = 0 - while "result" not in transaction: - transaction = await self.hass.async_add_executor_job( - self.coordinator.verisure.get_lock_state_transaction, - lock_state["doorLockStateChangeTransactionId"], - ) - attempts += 1 + while lock_status != "OK": if attempts == 30: break if attempts > 1: await asyncio.sleep(0.5) - if transaction["result"] == "OK": + attempts += 1 + poll_data = await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + self.coordinator.verisure.poll_lock_state( + transaction_id, self.serial_number, target_state + ), + ) + lock_status = ( + poll_data.get("data", {}) + .get("installation", {}) + .get("doorLockStateChangePollResult", {}) + .get("result") + ) + if lock_status == "OK": self._state = state def disable_autolock(self) -> None: diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 9e177a514a..66dccdc07d 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -1,7 +1,7 @@ { "domain": "verisure", "name": "Verisure", - "codeowners": ["@frenck"], + "codeowners": ["@frenck", "@niro1987"], "config_flow": true, "dhcp": [ { @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["verisure"], - "requirements": ["vsure==1.8.1"] + "requirements": ["vsure==2.6.1"] } diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index bbc1c15159..0b519b4726 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -28,18 +28,13 @@ async def async_setup_entry( sensors: list[Entity] = [ VerisureThermometer(coordinator, serial_number) for serial_number, values in coordinator.data["climate"].items() - if "temperature" in values + if "temperatureValue" in values ] sensors.extend( VerisureHygrometer(coordinator, serial_number) for serial_number, values in coordinator.data["climate"].items() - if "humidity" in values - ) - - sensors.extend( - VerisureMouseDetection(coordinator, serial_number) - for serial_number in coordinator.data["mice"] + if values.get("humidityEnabled") ) async_add_entities(sensors) @@ -67,10 +62,10 @@ class VerisureThermometer( @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - device_type = self.coordinator.data["climate"][self.serial_number].get( - "deviceType" - ) - area = self.coordinator.data["climate"][self.serial_number]["deviceArea"] + device_type = self.coordinator.data["climate"][self.serial_number]["device"][ + "gui" + ]["label"] + area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -84,7 +79,7 @@ class VerisureThermometer( @property def native_value(self) -> str | None: """Return the state of the entity.""" - return self.coordinator.data["climate"][self.serial_number]["temperature"] + return self.coordinator.data["climate"][self.serial_number]["temperatureValue"] @property def available(self) -> bool: @@ -92,7 +87,8 @@ class VerisureThermometer( return ( super().available and self.serial_number in self.coordinator.data["climate"] - and "temperature" in self.coordinator.data["climate"][self.serial_number] + and "temperatureValue" + in self.coordinator.data["climate"][self.serial_number] ) @@ -118,10 +114,10 @@ class VerisureHygrometer( @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - device_type = self.coordinator.data["climate"][self.serial_number].get( - "deviceType" - ) - area = self.coordinator.data["climate"][self.serial_number]["deviceArea"] + device_type = self.coordinator.data["climate"][self.serial_number]["device"][ + "gui" + ]["label"] + area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -135,7 +131,7 @@ class VerisureHygrometer( @property def native_value(self) -> str | None: """Return the state of the entity.""" - return self.coordinator.data["climate"][self.serial_number]["humidity"] + return self.coordinator.data["climate"][self.serial_number]["humidityValue"] @property def available(self) -> bool: @@ -143,51 +139,5 @@ class VerisureHygrometer( return ( super().available and self.serial_number in self.coordinator.data["climate"] - and "humidity" in self.coordinator.data["climate"][self.serial_number] - ) - - -class VerisureMouseDetection( - CoordinatorEntity[VerisureDataUpdateCoordinator], SensorEntity -): - """Representation of a Verisure mouse detector.""" - - _attr_name = "Mouse" - _attr_has_entity_name = True - _attr_native_unit_of_measurement = "Mice" - - def __init__( - self, coordinator: VerisureDataUpdateCoordinator, serial_number: str - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{serial_number}_mice" - self.serial_number = serial_number - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this entity.""" - area = self.coordinator.data["mice"][self.serial_number]["area"] - return DeviceInfo( - name=area, - suggested_area=area, - manufacturer="Verisure", - model="Mouse detector", - identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), - configuration_url="https://mypages.verisure.com", - ) - - @property - def native_value(self) -> str | None: - """Return the state of the entity.""" - return self.coordinator.data["mice"][self.serial_number]["detections"] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and self.serial_number in self.coordinator.data["mice"] - and "detections" in self.coordinator.data["mice"][self.serial_number] + and "humidityValue" in self.coordinator.data["climate"][self.serial_number] ) diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index ffb6e434fe..62e9bdf6cf 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -47,7 +47,9 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["smart_plugs"][self.serial_number]["area"] + area = self.coordinator.data["smart_plugs"][self.serial_number]["device"][ + "area" + ] return DeviceInfo( name=area, suggested_area=area, @@ -77,16 +79,23 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch and self.serial_number in self.coordinator.data["smart_plugs"] ) - def turn_on(self, **kwargs: Any) -> None: - """Set smartplug status on.""" - self.coordinator.verisure.set_smartplug_state(self.serial_number, True) - self._state = True - self._change_timestamp = monotonic() - self.schedule_update_ha_state() + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the smartplug on.""" + await self.async_set_plug_state(True) - def turn_off(self, **kwargs: Any) -> None: - """Set smartplug status off.""" - self.coordinator.verisure.set_smartplug_state(self.serial_number, False) - self._state = False + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the smartplug off.""" + await self.async_set_plug_state(False) + + async def async_set_plug_state(self, state: bool) -> None: + """Set smartplug state.""" + command: dict[ + str, str | dict[str, str] + ] = self.coordinator.verisure.set_smartplug(self.serial_number, state) + await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + command, + ) + self._state = state self._change_timestamp = monotonic() - self.schedule_update_ha_state() + await self.coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index 57e442108e..997a206f58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2589,7 +2589,7 @@ volkszaehler==0.4.0 volvooncall==0.10.2 # homeassistant.components.verisure -vsure==1.8.1 +vsure==2.6.1 # homeassistant.components.vasttrafik vtjp==0.1.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4366ec257d..e3c60c53ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1847,7 +1847,7 @@ vilfo-api-client==0.3.2 volvooncall==0.10.2 # homeassistant.components.verisure -vsure==1.8.1 +vsure==2.6.1 # homeassistant.components.vulcan vulcan-api==2.3.0 diff --git a/tests/components/verisure/conftest.py b/tests/components/verisure/conftest.py index f91215866d..8ddc3a9981 100644 --- a/tests/components/verisure/conftest.py +++ b/tests/components/verisure/conftest.py @@ -43,8 +43,22 @@ def mock_verisure_config_flow() -> Generator[None, MagicMock, None]: ) as verisure_mock: verisure = verisure_mock.return_value verisure.login.return_value = True - verisure.installations = [ - {"giid": "12345", "alias": "ascending", "street": "12345th street"}, - {"giid": "54321", "alias": "descending", "street": "54321th street"}, - ] + verisure.get_installations.return_value = { + "data": { + "account": { + "installations": [ + { + "giid": "12345", + "alias": "ascending", + "address": {"street": "12345th street"}, + }, + { + "giid": "54321", + "alias": "descending", + "address": {"street": "54321th street"}, + }, + ] + } + } + } yield verisure diff --git a/tests/components/verisure/test_config_flow.py b/tests/components/verisure/test_config_flow.py index e330a341d8..b1e67766df 100644 --- a/tests/components/verisure/test_config_flow.py +++ b/tests/components/verisure/test_config_flow.py @@ -35,9 +35,10 @@ async def test_full_user_flow_single_installation( assert result.get("type") == FlowResultType.FORM assert result.get("errors") == {} - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -133,9 +134,10 @@ async def test_full_user_flow_single_installation_with_mfa( assert result2.get("step_id") == "mfa" mock_verisure_config_flow.login.side_effect = None - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -153,9 +155,9 @@ async def test_full_user_flow_single_installation_with_mfa( CONF_PASSWORD: "SuperS3cr3t!", } - assert len(mock_verisure_config_flow.login.mock_calls) == 2 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1 + assert len(mock_verisure_config_flow.login.mock_calls) == 1 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 1 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -215,9 +217,9 @@ async def test_full_user_flow_multiple_installations_with_mfa( CONF_PASSWORD: "SuperS3cr3t!", } - assert len(mock_verisure_config_flow.login.mock_calls) == 2 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1 + assert len(mock_verisure_config_flow.login.mock_calls) == 1 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 1 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -257,7 +259,7 @@ async def test_verisure_errors( mock_verisure_config_flow.login.side_effect = VerisureLoginError( "Multifactor authentication enabled, disable or create MFA cookie" ) - mock_verisure_config_flow.login_mfa.side_effect = side_effect + mock_verisure_config_flow.request_mfa.side_effect = side_effect result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -268,7 +270,7 @@ async def test_verisure_errors( ) await hass.async_block_till_done() - mock_verisure_config_flow.login_mfa.side_effect = None + mock_verisure_config_flow.request_mfa.side_effect = None assert result3.get("type") == FlowResultType.FORM assert result3.get("step_id") == "user" @@ -286,7 +288,7 @@ async def test_verisure_errors( assert result4.get("type") == FlowResultType.FORM assert result4.get("step_id") == "mfa" - mock_verisure_config_flow.mfa_validate.side_effect = side_effect + mock_verisure_config_flow.validate_mfa.side_effect = side_effect result5 = await hass.config_entries.flow.async_configure( result4["flow_id"], @@ -298,11 +300,11 @@ async def test_verisure_errors( assert result5.get("step_id") == "mfa" assert result5.get("errors") == {"base": error} - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] - - mock_verisure_config_flow.mfa_validate.side_effect = None + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } + mock_verisure_config_flow.validate_mfa.side_effect = None mock_verisure_config_flow.login.side_effect = None result6 = await hass.config_entries.flow.async_configure( @@ -321,9 +323,9 @@ async def test_verisure_errors( CONF_PASSWORD: "SuperS3cr3t!", } - assert len(mock_verisure_config_flow.login.mock_calls) == 4 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 2 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 2 + assert len(mock_verisure_config_flow.login.mock_calls) == 3 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 2 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 2 assert len(mock_setup_entry.mock_calls) == 1 @@ -441,8 +443,8 @@ async def test_reauth_flow_with_mfa( } assert len(mock_verisure_config_flow.login.mock_calls) == 2 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 1 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -491,7 +493,7 @@ async def test_reauth_flow_errors( mock_verisure_config_flow.login.side_effect = VerisureLoginError( "Multifactor authentication enabled, disable or create MFA cookie" ) - mock_verisure_config_flow.login_mfa.side_effect = side_effect + mock_verisure_config_flow.request_mfa.side_effect = side_effect result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -506,7 +508,7 @@ async def test_reauth_flow_errors( assert result3.get("step_id") == "reauth_confirm" assert result3.get("errors") == {"base": "unknown_mfa"} - mock_verisure_config_flow.login_mfa.side_effect = None + mock_verisure_config_flow.request_mfa.side_effect = None result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], @@ -520,7 +522,7 @@ async def test_reauth_flow_errors( assert result4.get("type") == FlowResultType.FORM assert result4.get("step_id") == "reauth_mfa" - mock_verisure_config_flow.mfa_validate.side_effect = side_effect + mock_verisure_config_flow.validate_mfa.side_effect = side_effect result5 = await hass.config_entries.flow.async_configure( result4["flow_id"], @@ -532,11 +534,12 @@ async def test_reauth_flow_errors( assert result5.get("step_id") == "reauth_mfa" assert result5.get("errors") == {"base": error} - mock_verisure_config_flow.mfa_validate.side_effect = None + mock_verisure_config_flow.validate_mfa.side_effect = None mock_verisure_config_flow.login.side_effect = None - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } await hass.config_entries.flow.async_configure( result5["flow_id"], @@ -553,8 +556,8 @@ async def test_reauth_flow_errors( } assert len(mock_verisure_config_flow.login.mock_calls) == 4 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 2 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 2 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 2 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 2 assert len(mock_setup_entry.mock_calls) == 1 From a036e31495b4ce4da4532a6eb90563e600c536cd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:51:48 +0200 Subject: [PATCH 0147/1165] Use SnapshotAssertion in gree switch tests (#90222) --- .../gree/snapshots/test_switch.ambr | 206 ++++++++++++++++++ tests/components/gree/test_switch.py | 50 ++--- 2 files changed, 231 insertions(+), 25 deletions(-) create mode 100644 tests/components/gree/snapshots/test_switch.ambr diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr new file mode 100644 index 0000000000..73056fcc46 --- /dev/null +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -0,0 +1,206 @@ +# serializer version: 1 +# name: test_entity_state + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Panel Light', + 'icon': 'mdi:lightbulb', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_panel_light', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Quiet', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_quiet', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Fresh Air', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_fresh_air', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 XFan', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_xfan', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Health mode', + 'icon': 'mdi:pine-tree', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_health_mode', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + ]) +# --- +# name: test_registry_settings + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_device_1_panel_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:lightbulb', + 'original_name': 'fake-device-1 Panel Light', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Panel Light', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_device_1_quiet', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake-device-1 Quiet', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Quiet', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_device_1_fresh_air', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake-device-1 Fresh Air', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Fresh Air', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_device_1_xfan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake-device-1 XFan', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_XFan', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_device_1_health_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pine-tree', + 'original_name': 'fake-device-1 Health mode', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Health mode', + 'unit_of_measurement': None, + }), + ]) +# --- diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index 58c740b859..aee9c985e8 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -1,13 +1,14 @@ """Tests for gree component.""" +from unittest.mock import patch from greeclimate.exceptions import DeviceTimeoutError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.gree.const import DOMAIN as GREE_DOMAIN from homeassistant.components.switch import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -27,21 +28,26 @@ ENTITY_ID_FRESH_AIR = f"{DOMAIN}.fake_device_1_fresh_air" ENTITY_ID_XFAN = f"{DOMAIN}.fake_device_1_xfan" -async def async_setup_gree(hass): +async def async_setup_gree(hass: HomeAssistant) -> MockConfigEntry: """Set up the gree switch platform.""" - MockConfigEntry(domain=GREE_DOMAIN).add_to_hass(hass) + entry = MockConfigEntry(domain=GREE_DOMAIN) + entry.add_to_hass(hass) await async_setup_component(hass, GREE_DOMAIN, {GREE_DOMAIN: {DOMAIN: {}}}) await hass.async_block_till_done() + return entry -async def test_health_mode_disabled_by_default(hass): - """Test for making sure health mode is disabled on first load.""" - await async_setup_gree(hass) +@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +async def test_registry_settings( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for entity registry settings (disabled_by, unique_id).""" + entry = await async_setup_gree(hass) - assert ( - er.async_get(hass).async_get(ENTITY_ID_HEALTH_MODE).disabled_by - == er.RegistryEntryDisabler.INTEGRATION - ) + state = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert state == snapshot @pytest.mark.parametrize( @@ -183,20 +189,14 @@ async def test_send_switch_toggle( assert state.state == STATE_ON -@pytest.mark.parametrize( - ("entity", "name"), - [ - (ENTITY_ID_LIGHT_PANEL, "Panel Light"), - (ENTITY_ID_HEALTH_MODE, "Health mode"), - (ENTITY_ID_QUIET, "Quiet"), - (ENTITY_ID_FRESH_AIR, "Fresh Air"), - (ENTITY_ID_XFAN, "XFan"), - ], -) -async def test_entity_name( - hass: HomeAssistant, entity, name, entity_registry_enabled_by_default: None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test for name property.""" + """Test for entity registry settings (disabled_by, unique_id).""" await async_setup_gree(hass) - state = hass.states.get(entity) - assert state.attributes[ATTR_FRIENDLY_NAME] == f"fake-device-1 {name}" + + state = hass.states.async_all(DOMAIN) + assert state == snapshot From 3058cc8d56eff58e56ceec3b066592bbd0877f0c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:52:55 +0200 Subject: [PATCH 0148/1165] Adjust targets type hint in notify platform (#90062) --- homeassistant/components/notify/legacy.py | 4 ++-- pylint/plugins/hass_enforce_type_hints.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 2d91e1c065..110671864e 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Mapping from functools import partial from typing import Any, Protocol, cast @@ -221,7 +221,7 @@ class BaseNotificationService: registered_targets: dict[str, Any] @property - def targets(self) -> dict[str, Any] | None: + def targets(self) -> Mapping[str, Any] | None: """Return a dictionary of registered targets.""" return None diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 8dbb041fa9..7d11237fe5 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2035,7 +2035,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { matches=[ TypeHintMatch( function_name="targets", - return_type=["dict[str, Any]", None], + return_type=["Mapping[str, Any]", None], ), TypeHintMatch( function_name="send_message", From 3b83340f6e0f7a1119e5dd04fa879fe67cc027ed Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:54:01 +0200 Subject: [PATCH 0149/1165] Improve get_browse_image type hints in media player (#90057) --- homeassistant/components/braviatv/media_player.py | 2 +- homeassistant/components/forked_daapd/media_player.py | 2 +- homeassistant/components/kodi/media_player.py | 2 +- homeassistant/components/media_player/__init__.py | 2 +- homeassistant/components/philips_js/media_player.py | 2 +- homeassistant/components/roku/media_player.py | 2 +- homeassistant/components/sonos/media_player.py | 2 +- homeassistant/components/squeezebox/media_player.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index c09df32aea..ff5691f9ae 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -231,7 +231,7 @@ class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index d5f40c37b5..e1f1ece055 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -873,7 +873,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 63875236be..3272491a06 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -928,7 +928,7 @@ class KodiEntity(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 8810ea165d..0f827d6073 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1138,7 +1138,7 @@ class MediaPlayerImageView(HomeAssistantView): self, request: web.Request, entity_id: str, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> web.Response: """Start a get request.""" diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index e8250dc8eb..c6ca70bdc8 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -415,7 +415,7 @@ class PhilipsTVMediaPlayer( async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index cf6563519f..877e58233d 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -265,7 +265,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 1ef86429cb..cb18ec4388 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -691,7 +691,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 5c6f45c6ae..d3fae39bc4 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -634,7 +634,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 7d11237fe5..9f4c806dc9 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -1776,7 +1776,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="async_get_browse_image", arg_types={ - 1: "str", + 1: "MediaType | str", 2: "str", 3: "str | None", }, From c075dac9163dd36aca48417a0ecee9b2bb3d4c96 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:54:21 +0200 Subject: [PATCH 0150/1165] Fix pylint plugin for tuple[float, float] returns (#90047) --- pylint/plugins/hass_enforce_type_hints.py | 2 +- tests/pylint/test_enforce_type_hints.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 9f4c806dc9..63cc5a1c6b 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2796,7 +2796,7 @@ def _is_valid_type( _is_valid_type(match.group(1), node.value) and isinstance(node.slice, nodes.Tuple) and all( - _is_valid_type(match.group(n + 2), node.slice.elts[n]) + _is_valid_type(match.group(n + 2), node.slice.elts[n], in_return) for n in range(len(node.slice.elts)) ) ) diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index c580658b54..9e8df452b6 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -776,7 +776,7 @@ def test_valid_long_tuple( # Set ignore option type_hint_checker.config.ignore_missing_annotations = False - class_node, _, _ = astroid.extract_node( + class_node, _, _, _ = astroid.extract_node( """ class Entity(): pass @@ -790,6 +790,12 @@ def test_valid_long_tuple( class TestLight( #@ LightEntity ): + @property + def hs_color( #@ + self + ) -> tuple[int, int]: + pass + @property def rgbw_color( #@ self From 89355e087952417a6824507fd3b197f9d0520e19 Mon Sep 17 00:00:00 2001 From: Alexey Baturin Date: Sun, 26 Mar 2023 20:03:03 +0200 Subject: [PATCH 0151/1165] Add WLED IP as a sensor (#90241) Co-authored-by: Franck Nijhof --- homeassistant/components/wled/sensor.py | 7 +++++++ tests/components/wled/fixtures/rgb.json | 3 ++- tests/components/wled/fixtures/rgb_no_update.json | 3 ++- tests/components/wled/fixtures/rgb_single_segment.json | 3 ++- tests/components/wled/fixtures/rgb_websocket.json | 3 ++- tests/components/wled/fixtures/rgbw.json | 3 ++- tests/components/wled/snapshots/test_diagnostics.ambr | 2 +- tests/components/wled/test_sensor.py | 9 +++++++++ 8 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 924414cadf..668b90159b 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -128,6 +128,13 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.bssid if device.info.wifi else None, ), + WLEDSensorEntityDescription( + key="ip", + name="IP", + icon="mdi:ip-network", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.info.ip, + ), ) diff --git a/tests/components/wled/fixtures/rgb.json b/tests/components/wled/fixtures/rgb.json index c66c07b339..21f9b005b7 100644 --- a/tests/components/wled/fixtures/rgb.json +++ b/tests/components/wled/fixtures/rgb.json @@ -86,7 +86,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgb_no_update.json b/tests/components/wled/fixtures/rgb_no_update.json index a3c54dd118..c8aa902cc9 100644 --- a/tests/components/wled/fixtures/rgb_no_update.json +++ b/tests/components/wled/fixtures/rgb_no_update.json @@ -86,7 +86,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgb_single_segment.json b/tests/components/wled/fixtures/rgb_single_segment.json index 08b1cb7bb6..aa0b79e98f 100644 --- a/tests/components/wled/fixtures/rgb_single_segment.json +++ b/tests/components/wled/fixtures/rgb_single_segment.json @@ -68,7 +68,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgb_websocket.json b/tests/components/wled/fixtures/rgb_websocket.json index 36ca3e1f79..4a0ed7b1ee 100644 --- a/tests/components/wled/fixtures/rgb_websocket.json +++ b/tests/components/wled/fixtures/rgb_websocket.json @@ -94,7 +94,8 @@ "opt": 127, "brand": "WLED", "product": "FOSS", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgbw.json b/tests/components/wled/fixtures/rgbw.json index 7ffcaa36f9..100b393690 100644 --- a/tests/components/wled/fixtures/rgbw.json +++ b/tests/components/wled/fixtures/rgbw.json @@ -68,7 +68,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddee11" + "mac": "aabbccddee11", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/snapshots/test_diagnostics.ambr b/tests/components/wled/snapshots/test_diagnostics.ambr index 25db6a3116..643e5fe4ad 100644 --- a/tests/components/wled/snapshots/test_diagnostics.ambr +++ b/tests/components/wled/snapshots/test_diagnostics.ambr @@ -92,7 +92,7 @@ 'effect_count': 81, 'filesystem': None, 'free_heap': 14600, - 'ip': 'Unknown', + 'ip': '127.0.0.1', 'leds': dict({ '__type': "", 'repr': 'Leds(cct=False, count=30, fps=None, light_capabilities=None, max_power=850, max_segments=10, power=470, rgbw=False, wv=True, segment_light_capabilities=None)', diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index f4016ce37a..d9168d7b69 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -110,6 +110,15 @@ async def test_sensors( assert entry.unique_id == "aabbccddeeff_wifi_bssid" assert entry.entity_category is EntityCategory.DIAGNOSTIC + assert (state := hass.states.get("sensor.wled_rgb_light_ip")) + assert state.attributes.get(ATTR_ICON) == "mdi:ip-network" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.state == "127.0.0.1" + + assert (entry := entity_registry.async_get("sensor.wled_rgb_light_ip")) + assert entry.unique_id == "aabbccddeeff_ip" + assert entry.entity_category is EntityCategory.DIAGNOSTIC + @pytest.mark.parametrize( "entity_id", From 542def7f82212b4734128a6945312610bc95821f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 09:10:35 -1000 Subject: [PATCH 0152/1165] Bump pySwitchbot to 0.37.5 (#90317) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index ada24bcee5..31ce20bea3 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -40,5 +40,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.37.4"] + "requirements": ["PySwitchbot==0.37.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 997a206f58..343660ab3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,7 +40,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.4 +PySwitchbot==0.37.5 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3c60c53ba..2cdff72fc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.4 +PySwitchbot==0.37.5 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 From 45262c61145f080673938078aa15a5b31877cfaa Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 26 Mar 2023 21:14:17 +0200 Subject: [PATCH 0153/1165] Implement config flow for nextcloud (#89396) * implement config flow * add tests * fix hassfest and requirements * abort import on connection error * add add_suggested_values_to_schema * mock async_setup_entry * revert code owner change * fix try connect in config flow * add device info * allow multiple instances * fix import in config flow * remove custom scan interval from coordinator * applay suggestions * apply suggestions * take over ownership from @meichthys * cleanup import data before passing to user step * apply suggestions to tests * add untested files to .coveragerc --- .coveragerc | 7 +- CODEOWNERS | 3 +- .../components/nextcloud/__init__.py | 73 ++++++--- .../components/nextcloud/binary_sensor.py | 21 +-- .../components/nextcloud/config_flow.py | 78 +++++++++ .../components/nextcloud/coordinator.py | 11 +- .../components/nextcloud/manifest.json | 3 +- homeassistant/components/nextcloud/sensor.py | 20 +-- .../components/nextcloud/strings.json | 28 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/nextcloud/__init__.py | 1 + tests/components/nextcloud/conftest.py | 25 +++ .../nextcloud/snapshots/test_config_flow.ambr | 15 ++ .../components/nextcloud/test_config_flow.py | 151 ++++++++++++++++++ 16 files changed, 383 insertions(+), 59 deletions(-) create mode 100644 homeassistant/components/nextcloud/config_flow.py create mode 100644 homeassistant/components/nextcloud/strings.json create mode 100644 tests/components/nextcloud/__init__.py create mode 100644 tests/components/nextcloud/conftest.py create mode 100644 tests/components/nextcloud/snapshots/test_config_flow.ambr create mode 100644 tests/components/nextcloud/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index f3dbc34791..17db4ef9cd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -778,7 +778,12 @@ omit = homeassistant/components/nexia/climate.py homeassistant/components/nexia/entity.py homeassistant/components/nexia/switch.py - homeassistant/components/nextcloud/* + homeassistant/components/nextcloud/__init__.py + homeassistant/components/nextcloud/binary_sensor.py + homeassistant/components/nextcloud/const.py + homeassistant/components/nextcloud/coordinator.py + homeassistant/components/nextcloud/entity.py + homeassistant/components/nextcloud/sensor.py homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/nibe_heatpump/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index ff31997ce6..1acd5f6c9f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -785,7 +785,8 @@ build.json @home-assistant/supervisor /tests/components/nexia/ @bdraco /homeassistant/components/nextbus/ @vividboarder /tests/components/nextbus/ @vividboarder -/homeassistant/components/nextcloud/ @meichthys +/homeassistant/components/nextcloud/ @mib1185 +/tests/components/nextcloud/ @mib1185 /homeassistant/components/nextdns/ @bieniu /tests/components/nextdns/ @bieniu /homeassistant/components/nfandroidtv/ @tkdrob diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 5dffcbf9fb..d2ad3edf1c 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -4,6 +4,7 @@ import logging from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, @@ -12,42 +13,71 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import DEFAULT_SCAN_INTERVAL, DOMAIN from .coordinator import NextcloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) - PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) # Validate user configuration CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_URL): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_URL): cv.url, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + }, + ) + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Nextcloud integration.""" - conf = config[DOMAIN] + if DOMAIN in config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Nextcloud integration.""" + + def _connect_nc(): + return NextcloudMonitor( + entry.data[CONF_URL], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + ) try: - ncm = await hass.async_add_executor_job( - NextcloudMonitor, conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD] - ) + ncm = await hass.async_add_executor_job(_connect_nc) except NextcloudMonitorError: _LOGGER.error("Nextcloud setup failed - Check configuration") return False @@ -55,13 +85,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: coordinator = NextcloudDataUpdateCoordinator( hass, ncm, - conf, + entry, ) - hass.data[DOMAIN] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await coordinator.async_config_entry_first_refresh() - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 52ddb66007..0d960bea8e 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -2,9 +2,9 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator @@ -18,24 +18,17 @@ BINARY_SENSORS = ( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the Nextcloud sensors.""" - if discovery_info is None: - return - coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] - - add_entities( + """Set up the Nextcloud binary sensors.""" + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( [ NextcloudBinarySensor(coordinator, name) for name in coordinator.data if name in BINARY_SENSORS - ], - True, + ] ) diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py new file mode 100644 index 0000000000..e297a6893a --- /dev/null +++ b/homeassistant/components/nextcloud/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow to configure the Nextcloud integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +DATA_SCHEMA_USER = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) +_LOGGER = logging.getLogger(__name__) + + +class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a Nextcloud config flow.""" + + VERSION = 1 + + def _try_connect_nc(self, user_input: dict) -> NextcloudMonitor: + """Try to connect to nextcloud server.""" + return NextcloudMonitor( + user_input[CONF_URL], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle a flow initiated by configuration file.""" + self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)}) + try: + await self.hass.async_add_executor_job(self._try_connect_nc, user_input) + except NextcloudMonitorError: + _LOGGER.error( + "Connection error during import of yaml configuration, import aborted" + ) + return self.async_abort(reason="connection_error_during_import") + return await self.async_step_user( + { + CONF_URL: user_input[CONF_URL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)}) + try: + await self.hass.async_add_executor_job(self._try_connect_nc, user_input) + except NextcloudMonitorError: + errors["base"] = "connection_error" + else: + return self.async_create_entry( + title=user_input[CONF_URL], + data=user_input, + ) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, user_input) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py index 07dc76d41d..73a07a77e2 100644 --- a/homeassistant/components/nextcloud/coordinator.py +++ b/homeassistant/components/nextcloud/coordinator.py @@ -5,9 +5,9 @@ from typing import Any from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError -from homeassistant.const import CONF_SCAN_INTERVAL, CONF_URL +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -19,18 +19,17 @@ class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Nextcloud data update coordinator.""" def __init__( - self, hass: HomeAssistant, ncm: NextcloudMonitor, config: ConfigType + self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: ConfigEntry ) -> None: """Initialize the Nextcloud coordinator.""" - self.config = config self.ncm = ncm - self.url = config[CONF_URL] + self.url = entry.data[CONF_URL] super().__init__( hass, _LOGGER, name=self.url, - update_interval=config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + update_interval=DEFAULT_SCAN_INTERVAL, ) # Use recursion to create list of sensors & values based on nextcloud api data diff --git a/homeassistant/components/nextcloud/manifest.json b/homeassistant/components/nextcloud/manifest.json index 366c6eeb56..72e992277c 100644 --- a/homeassistant/components/nextcloud/manifest.json +++ b/homeassistant/components/nextcloud/manifest.json @@ -1,7 +1,8 @@ { "domain": "nextcloud", "name": "Nextcloud", - "codeowners": ["@meichthys"], + "codeowners": ["@mib1185"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nextcloud", "iot_class": "cloud_polling", "requirements": ["nextcloudmonitor==1.1.0"] diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 459f22d30e..eb6043e4bc 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator @@ -57,24 +58,17 @@ SENSORS = ( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nextcloud sensors.""" - if discovery_info is None: - return - coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] - - add_entities( + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( [ NextcloudSensor(coordinator, name) for name in coordinator.data if name in SENSORS - ], - True, + ] ) diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json new file mode 100644 index 0000000000..9ae7ed24a6 --- /dev/null +++ b/homeassistant/components/nextcloud/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "flow_title": "Nextcloud", + "step": { + "user": { + "description": "Enter your Nextcloud information.", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "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%]", + "connection_error_during_import": "Connection error occured during yaml configuration import" + }, + "error": { + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Netxcloud YAML configuration has been deprecated", + "description": "Configuring Netxcloud using YAML has been deprecated.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `nextcloud` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6656972f8b..37480904f9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -282,6 +282,7 @@ FLOWS = { "netatmo", "netgear", "nexia", + "nextcloud", "nextdns", "nfandroidtv", "nibe_heatpump", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 843b8ed006..7340980f13 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3616,7 +3616,7 @@ "nextcloud": { "name": "Nextcloud", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "nextdns": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cdff72fc8..6573d81e55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -887,6 +887,9 @@ nettigo-air-monitor==2.1.0 # homeassistant.components.nexia nexia==2.0.6 +# homeassistant.components.nextcloud +nextcloudmonitor==1.1.0 + # homeassistant.components.discord nextcord==2.0.0a8 diff --git a/tests/components/nextcloud/__init__.py b/tests/components/nextcloud/__init__.py new file mode 100644 index 0000000000..e2102ed8c2 --- /dev/null +++ b/tests/components/nextcloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nextcloud integration.""" diff --git a/tests/components/nextcloud/conftest.py b/tests/components/nextcloud/conftest.py new file mode 100644 index 0000000000..0ea281abb4 --- /dev/null +++ b/tests/components/nextcloud/conftest.py @@ -0,0 +1,25 @@ +"""Fixtrues for the Nextcloud integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest + + +@pytest.fixture +def mock_nextcloud_monitor() -> Mock: + """Mock of NextcloudMonitor.""" + ncm = Mock( + update=Mock(return_value=True), + ) + + return ncm + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nextcloud.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr new file mode 100644 index 0000000000..0c9df1238c --- /dev/null +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_import + dict({ + 'password': 'nc_pass', + 'url': 'nc_url', + 'username': 'nc_user', + }) +# --- +# name: test_user_create_entry + dict({ + 'password': 'nc_pass', + 'url': 'nc_url', + 'username': 'nc_user', + }) +# --- diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py new file mode 100644 index 0000000000..118d8fef0d --- /dev/null +++ b/tests/components/nextcloud/test_config_flow.py @@ -0,0 +1,151 @@ +"""Tests for the Nextcloud config flow.""" +from unittest.mock import Mock, patch + +from nextcloudmonitor import NextcloudMonitorError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nextcloud import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +VALID_CONFIG = {CONF_URL: "nc_url", CONF_USERNAME: "nc_user", CONF_PASSWORD: "nc_pass"} + + +async def test_user_create_entry( + hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion +) -> None: + """Test that the user step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "connection_error"} + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "nc_url" + assert result["data"] == snapshot + + +async def test_user_already_configured( + hass: HomeAssistant, mock_nextcloud_monitor: Mock +) -> None: + """Test that errors are shown when duplicates are added.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="nc_url", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import( + hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion +) -> None: + """Test that the import step works.""" + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "nc_url" + assert result["data"] == snapshot + + +async def test_import_already_configured( + hass: HomeAssistant, mock_nextcloud_monitor: Mock +) -> None: + """Test that import step is aborted when duplicates are added.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="nc_url", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_connection_error(hass: HomeAssistant) -> None: + """Test that import step is aborted on connection error.""" + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "connection_error_during_import" From 94cc188885503d0e326f8a60d974676dea2c3d74 Mon Sep 17 00:00:00 2001 From: Igor Santos <532299+igorsantos07@users.noreply.github.com> Date: Sun, 26 Mar 2023 16:37:24 -0300 Subject: [PATCH 0154/1165] [Issue template] Point to health page for version info (#80708) --- .github/ISSUE_TEMPLATE/bug_report.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5bb755750e..237fc2888a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,9 +31,9 @@ body: label: What version of Home Assistant Core has the issue? placeholder: core- description: > - Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/). + Can be found in: [Settings ⇒ System ⇒ Repairs ⇒ Three Dots in Upper Right ⇒ System information](https://my.home-assistant.io/redirect/system_health/). - [![Open your Home Assistant instance and show your Home Assistant version information.](https://my.home-assistant.io/badges/info.svg)](https://my.home-assistant.io/redirect/info/) + [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) - type: input attributes: label: What was the last working version of Home Assistant Core? @@ -46,9 +46,9 @@ body: attributes: label: What type of installation are you running? description: > - Can be found in: [Settings -> System-> Repairs -> Three Dots in Upper Right -> System information](https://my.home-assistant.io/redirect/system_health/). + Can be found in: [Settings ⇒ System ⇒ Repairs ⇒ Three Dots in Upper Right ⇒ System information](https://my.home-assistant.io/redirect/system_health/). - [![Open your Home Assistant instance and show health information about your system.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) + [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) options: - Home Assistant OS - Home Assistant Container From 916b274ec89dc7648479cebaf1a0432e0b81a7ad Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 26 Mar 2023 21:47:47 +0200 Subject: [PATCH 0155/1165] Update pipdeptree to 2.7.0 (#90312) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index fa16881520..caf29fc558 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,7 +17,7 @@ pre-commit==3.1.0 pydantic==1.10.7 pylint==2.17.0 pylint-per-file-ignores==1.1.0 -pipdeptree==2.5.0 +pipdeptree==2.7.0 pytest-asyncio==0.20.3 pytest-aiohttp==1.0.4 pytest-cov==3.0.0 From 8c9966aa0533c2cfd23461ecaa819e90380884b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 10:06:14 -1000 Subject: [PATCH 0156/1165] Ensure esphome subscribes to bluetooth connection free before accepting connect requests (#90319) --- .../components/esphome/bluetooth/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index 4a70b906b1..e62b54655c 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -78,15 +78,18 @@ async def async_connect_scanner( scanner = ESPHomeScanner( hass, source, entry.title, new_info_callback, connector, connectable ) + if connectable: + # If its connectable be sure not to register the scanner + # until we know the connection is fully setup since otherwise + # there is a race condition where the connection can fail + await cli.subscribe_bluetooth_connections_free( + entry_data.async_update_ble_connection_limits + ) unload_callbacks = [ async_register_scanner(hass, scanner, connectable), scanner.async_setup(), ] await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) - if connectable: - await cli.subscribe_bluetooth_connections_free( - entry_data.async_update_ble_connection_limits - ) @hass_callback def _async_unload() -> None: From 745df277a0ae0875228fa311d79c887cc9609ea2 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Sun, 26 Mar 2023 13:08:36 -0700 Subject: [PATCH 0157/1165] Fix Todoist end date for all day event (#89837) --- homeassistant/components/todoist/calendar.py | 2 +- tests/components/todoist/test_calendar.py | 56 +++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 8fdafee6cf..02459b429c 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -612,7 +612,7 @@ class TodoistProjectData: event = CalendarEvent( summary=task.content, start=due_date_value, - end=due_date_value, + end=due_date_value + timedelta(days=1), ) events.append(event) return events diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 82eff0d755..adf0f8a14b 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -1,6 +1,8 @@ """Unit tests for the Todoist calendar platform.""" -from datetime import datetime +from datetime import datetime, timedelta +from http import HTTPStatus from unittest.mock import AsyncMock, patch +import urllib import pytest from todoist_api_python.models import Due, Label, Project, Task @@ -11,6 +13,9 @@ from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.util import dt + +from tests.typing import ClientSessionGenerator @pytest.fixture(name="task") @@ -68,6 +73,11 @@ def mock_api(task) -> AsyncMock: return api +def get_events_url(entity: str, start: str, end: str) -> str: + """Create a url to get events during the specified time range.""" + return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + + @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") async def test_calendar_entity_unique_id( todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry @@ -139,3 +149,47 @@ async def test_calendar_custom_project_unique_id( state = hass.states.get("calendar.all_projects") assert state.state == "off" + + +@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +async def test_all_day_event( + todoist_api, hass: HomeAssistant, hass_client: ClientSessionGenerator, api +) -> None: + """Test for an all day calendar event.""" + todoist_api.return_value = api + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + "custom_projects": [{"name": "All projects", "labels": ["Label1"]}], + } + }, + ) + await hass.async_block_till_done() + + await async_update_entity(hass, "calendar.all_projects") + client = await hass_client() + start = dt.now() - timedelta(days=1) + end = dt.now() + timedelta(days=1) + response = await client.get( + get_events_url("calendar.all_projects", start.isoformat(), end.isoformat()) + ) + assert response.status == HTTPStatus.OK + events = await response.json() + + expected = [ + { + "start": {"date": dt.now().strftime("%Y-%m-%d")}, + "end": {"date": (dt.now() + timedelta(days=1)).strftime("%Y-%m-%d")}, + "summary": "A task", + "description": None, + "location": None, + "uid": None, + "recurrence_id": None, + "rrule": None, + } + ] + assert events == expected From fa35867765cb38a39c0e07c6c34ad771a5dec112 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Mar 2023 14:00:45 -0700 Subject: [PATCH 0158/1165] Bump ical to 4.5.1 and set PRODID for home assistant in local calendar ics (#90291) --- homeassistant/components/local_calendar/calendar.py | 3 +++ homeassistant/components/local_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/local_calendar/snapshots/test_diagnostics.ambr | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 2905e98caa..718c65ffce 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -33,6 +33,8 @@ from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) +PRODID = "-//homeassistant.io//local_calendar 1.0//EN" + async def async_setup_entry( hass: HomeAssistant, @@ -43,6 +45,7 @@ async def async_setup_entry( store = hass.data[DOMAIN][config_entry.entry_id] ics = await store.async_load() calendar = IcsCalendarStream.calendar_from_ics(ics) + calendar.prodid = PRODID name = config_entry.data[CONF_CALENDAR_NAME] entity = LocalCalendarEntity(store, calendar, name, unique_id=config_entry.entry_id) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 42cd7fcf5a..049f9de03e 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==4.5.0"] + "requirements": ["ical==4.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 343660ab3d..b7f2f6a181 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -952,7 +952,7 @@ ibm-watson==5.2.2 ibmiotf==0.3.4 # homeassistant.components.local_calendar -ical==4.5.0 +ical==4.5.1 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6573d81e55..0c4581d87e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -723,7 +723,7 @@ iaqualink==0.5.0 ibeacon_ble==1.0.1 # homeassistant.components.local_calendar -ical==4.5.0 +ical==4.5.1 # homeassistant.components.ping icmplib==3.0 diff --git a/tests/components/local_calendar/snapshots/test_diagnostics.ambr b/tests/components/local_calendar/snapshots/test_diagnostics.ambr index e61b9da7a9..a70b9d7438 100644 --- a/tests/components/local_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/local_calendar/snapshots/test_diagnostics.ambr @@ -3,7 +3,7 @@ dict({ 'ics': ''' BEGIN:VCALENDAR - PRODID:-//github.com/allenporter/ical//4.5.0//EN + PRODID:-//homeassistant.io//local_calendar 1.0//EN VERSION:*** BEGIN:VEVENT DTSTAMP:20230313T190500 From 0393797ade9b25054928fd50305e43646381aa14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20=C3=98stergaard=20Nielsen?= Date: Sun, 26 Mar 2023 23:32:06 +0200 Subject: [PATCH 0159/1165] Bump ihcsdk to 2.8.5 (#90266) --- homeassistant/components/ihc/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ihc/manifest.json b/homeassistant/components/ihc/manifest.json index 13bf8bb6d8..2400206c3a 100644 --- a/homeassistant/components/ihc/manifest.json +++ b/homeassistant/components/ihc/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/ihc", "iot_class": "local_push", "loggers": ["ihcsdk"], - "requirements": ["defusedxml==0.7.1", "ihcsdk==2.7.6"] + "requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7f2f6a181..d06ee4e2bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -964,7 +964,7 @@ ifaddr==0.1.7 iglo==1.2.7 # homeassistant.components.ihc -ihcsdk==2.7.6 +ihcsdk==2.8.5 # homeassistant.components.incomfort incomfort-client==0.5.0 From 2642d375052da2da43f5856e638fce8ada292625 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Sun, 26 Mar 2023 17:40:59 -0400 Subject: [PATCH 0160/1165] Set qbittorrent integration_type to service (#90236) --- homeassistant/components/qbittorrent/manifest.json | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 2c1a7be74f..47090ab8b9 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -3,6 +3,7 @@ "name": "qBittorrent", "codeowners": ["@geoffreylagaisse"], "documentation": "https://www.home-assistant.io/integrations/qbittorrent", + "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], "requirements": ["python-qbittorrent==0.4.2"] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7340980f13..4001adbd20 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4309,7 +4309,7 @@ }, "qbittorrent": { "name": "qBittorrent", - "integration_type": "hub", + "integration_type": "service", "config_flow": false, "iot_class": "local_polling" }, From c06ec1f78fb1fafa4d3da0737f7cc779514c8b57 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 23:46:52 +0200 Subject: [PATCH 0161/1165] Improve onewire test coverage (#90184) --- tests/components/onewire/test_init.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 5a69fb95e1..01c1841d17 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,4 +1,5 @@ """Tests for 1-Wire config flow.""" +from copy import deepcopy from unittest.mock import MagicMock, patch import aiohttp @@ -74,6 +75,27 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N assert not hass.data.get(DOMAIN) +async def test_update_options( + hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock +) -> None: + """Test update options triggers reload.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert owproxy.call_count == 1 + + new_options = deepcopy(dict(config_entry.options)) + new_options["device_options"].clear() + hass.config_entries.async_update_entry(config_entry, options=new_options) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert owproxy.call_count == 2 + + @patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]) async def test_registry_cleanup( hass: HomeAssistant, From 16028dc9bc1beaa9fa2e50fb901eb28c8a64ae3f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 26 Mar 2023 17:52:01 -0400 Subject: [PATCH 0162/1165] Add milliseconds as valid duration sensor unit (#90018) --- homeassistant/components/sensor/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 356eb68b4d..892bc611b3 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -485,6 +485,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.HOURS, UnitOfTime.MINUTES, UnitOfTime.SECONDS, + UnitOfTime.MILLISECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), SensorDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), From 00ce7570510f7cc5a215c2f5ef4cbf8d0e124b5a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 27 Mar 2023 00:10:57 +0200 Subject: [PATCH 0163/1165] Apply late review comments from #89396 in Nextcloud (#90327) --- .coveragerc | 1 - homeassistant/components/nextcloud/__init__.py | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index 17db4ef9cd..da7cc42ba1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -780,7 +780,6 @@ omit = homeassistant/components/nexia/switch.py homeassistant/components/nextcloud/__init__.py homeassistant/components/nextcloud/binary_sensor.py - homeassistant/components/nextcloud/const.py homeassistant/components/nextcloud/coordinator.py homeassistant/components/nextcloud/entity.py homeassistant/components/nextcloud/sensor.py diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index d2ad3edf1c..d2514b9091 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -94,3 +94,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Nextcloud integration.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return unload_ok From bdd095423b4dfdc6a4900a567cfdd2cc12f874f8 Mon Sep 17 00:00:00 2001 From: skrynklarn <20681457+skrynklarn@users.noreply.github.com> Date: Mon, 27 Mar 2023 00:17:12 +0200 Subject: [PATCH 0164/1165] Add last trip time attribute to Verisure binary sensors (#89944) --- homeassistant/components/verisure/binary_sensor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 536b96ea2c..a960107c71 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -6,11 +6,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import ATTR_LAST_TRIP_TIME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import CONF_GIID, DOMAIN from .coordinator import VerisureDataUpdateCoordinator @@ -79,6 +80,15 @@ class VerisureDoorWindowSensor( and self.serial_number in self.coordinator.data["door_window"] ) + @property + def extra_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_LAST_TRIP_TIME: dt_util.parse_datetime( + self.coordinator.data["door_window"][self.serial_number]["reportTime"] + ) + } + class VerisureEthernetStatus( CoordinatorEntity[VerisureDataUpdateCoordinator], BinarySensorEntity From a733ca96a2108ab35b7c5db212fc6b4aec1fda27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:17:32 -1000 Subject: [PATCH 0165/1165] Bump yalexs-ble to 2.1.9 (#90320) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index d30d3a39fb..7884ba6a4b 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.6"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.9"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index da9e0271e4..5c7adf09e3 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.6"] + "requirements": ["yalexs-ble==2.1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index d06ee4e2bf..68aece1846 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.6 +yalexs-ble==2.1.9 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c4581d87e..5b63fb59b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1908,7 +1908,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.6 +yalexs-ble==2.1.9 # homeassistant.components.august yalexs==1.2.7 From a7c796a2f7ccc7d609363824a5f2b720d72db546 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:30:00 -1000 Subject: [PATCH 0166/1165] Ensure esphome connected future is awaited when connecting is canceled (#90329) --- .../components/esphome/bluetooth/client.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 71d081ff6a..c332fc3441 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -322,15 +322,24 @@ class ESPHomeClient(BaseBleakClient): address_type=self._address_type, ) ) + except asyncio.CancelledError: + if connected_future.done(): + with contextlib.suppress(BleakError): + # If we are cancelled while connecting, + # we need to make sure we await the future + # to avoid a warning about an un-retrieved + # exception. + await connected_future + raise except Exception: - with contextlib.suppress(BleakError): - # If the connect call throws an exception, - # we need to make sure we await the future - # to avoid a warning about an un-retrieved - # exception since we prefer to raise the - # exception from the connect call as it - # will be more descriptive. - if connected_future.done(): + if connected_future.done(): + with contextlib.suppress(BleakError): + # If the connect call throws an exception, + # we need to make sure we await the future + # to avoid a warning about an un-retrieved + # exception since we prefer to raise the + # exception from the connect call as it + # will be more descriptive. await connected_future connected_future.cancel() raise From ce9099a38664524cbba8801130cd0107c19aa6c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:30:17 -1000 Subject: [PATCH 0167/1165] Bump cryptography to 40.0.1 (#90326) --- homeassistant/package_constraints.txt | 10 +++++----- pyproject.toml | 6 +++--- requirements.txt | 4 ++-- script/gen_requirements_all.py | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c91ab060b5..cd568947cf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==39.0.1 +cryptography==40.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 hass-nabucasa==0.62.0 @@ -35,7 +35,7 @@ paho-mqtt==1.6.1 pillow==9.4.0 pip>=21.0,<23.1 psutil-home-assistant==0.0.1 -pyOpenSSL==23.0.0 +pyOpenSSL==23.1.0 pyserial==3.5 python-slugify==4.0.1 pyudev==0.23.2 @@ -144,9 +144,9 @@ pandas==1.4.3;python_version<'3.11' # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 -# pyOpenSSL 23.0.0 or later required to avoid import errors when -# cryptography 39.0.0 is installed with botocore -pyOpenSSL>=23.0.0 +# pyOpenSSL 23.1.0 or later required to avoid import errors when +# cryptography 40.0.1 is installed with botocore +pyOpenSSL>=23.1.0 # uamqp newer versions we currently can't build for armv7/armhf # Limit this to Python 3.10, to not block Python 3.11 dev for now diff --git a/pyproject.toml b/pyproject.toml index 5d39a99c03..577ba18140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,9 @@ dependencies = [ "lru-dict==1.1.8", "PyJWT==2.6.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==39.0.1", - # pyOpenSSL 23.0.0 is required to work with cryptography 39+ - "pyOpenSSL==23.0.0", + "cryptography==40.0.1", + # pyOpenSSL 23.1.0 is required to work with cryptography 39+ + "pyOpenSSL==23.1.0", "orjson==3.8.7", "pip>=21.0,<23.1", "python-slugify==4.0.1", diff --git a/requirements.txt b/requirements.txt index 2386015c84..84726cb49d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,8 +16,8 @@ ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 PyJWT==2.6.0 -cryptography==39.0.1 -pyOpenSSL==23.0.0 +cryptography==40.0.1 +pyOpenSSL==23.1.0 orjson==3.8.7 pip>=21.0,<23.1 python-slugify==4.0.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index cd53635d96..564d0e2eb0 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -151,9 +151,9 @@ pandas==1.4.3;python_version<'3.11' # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 -# pyOpenSSL 23.0.0 or later required to avoid import errors when -# cryptography 39.0.0 is installed with botocore -pyOpenSSL>=23.0.0 +# pyOpenSSL 23.1.0 or later required to avoid import errors when +# cryptography 40.0.1 is installed with botocore +pyOpenSSL>=23.1.0 # uamqp newer versions we currently can't build for armv7/armhf # Limit this to Python 3.10, to not block Python 3.11 dev for now From 65e46e326171e4ca24ab7411ad6f7b7df3725039 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:58:07 -1000 Subject: [PATCH 0168/1165] Bump aioesphomeapi to 13.6.0 (#90330) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 95b6c091d5..ac98592da9 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.5.1", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.6.0", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 68aece1846..7500baa41a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.1 +aioesphomeapi==13.6.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b63fb59b6..3be11d6cd6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.1 +aioesphomeapi==13.6.0 # homeassistant.components.flo aioflo==2021.11.0 From 75e28826e06eb709c3102fdcb816986d8122d22f Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 27 Mar 2023 01:22:20 +0200 Subject: [PATCH 0169/1165] Upgrade netgear_lte third-party library to v0.0.15 (#90324) * Upgrade netgear_lte third-party library to 0.0.15 * Create explicit tasks for asyncio.wait() --- homeassistant/components/netgear_lte/__init__.py | 5 ++++- homeassistant/components/netgear_lte/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index fd2c399fb3..0ab3dd07ed 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -221,7 +221,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: netgear_lte_config = config[DOMAIN] # Set up each modem - tasks = [_setup_lte(hass, lte_conf) for lte_conf in netgear_lte_config] + tasks = [ + hass.async_create_task(_setup_lte(hass, lte_conf)) + for lte_conf in netgear_lte_config + ] await asyncio.wait(tasks) # Load platforms for each modem diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index ae580dbb99..427aa9633c 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/netgear_lte", "iot_class": "local_polling", "loggers": ["eternalegypt"], - "requirements": ["eternalegypt==0.0.12"] + "requirements": ["eternalegypt==0.0.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7500baa41a..31e78c40c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -679,7 +679,7 @@ epsonprinter==0.0.9 esphome-dashboard-api==1.2.3 # homeassistant.components.netgear_lte -eternalegypt==0.0.12 +eternalegypt==0.0.15 # homeassistant.components.eufylife_ble eufylife_ble_client==0.1.7 From 7098debe098a4c019c85222cfc73441ae6eda1f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 15:02:24 -1000 Subject: [PATCH 0170/1165] Fix sql doing I/O in the event loop at startup (#90335) * Fix sql doing I/O in the event loop * Fix sql doing I/O in the event loop * no test query on main db * fix mocking because it was targeting the recorder --- homeassistant/components/sql/sensor.py | 53 +++++++++++++++++--------- tests/components/sql/test_sensor.py | 16 +++++--- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 95227bac65..57818ef27e 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -136,24 +136,17 @@ async def async_setup_sensor( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" - try: - engine = sqlalchemy.create_engine(db_url, future=True) - sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) - - # Run a dummy query just to test the db_url - sess: Session = sessmaker() - sess.execute(sqlalchemy.text("SELECT 1;")) - - except SQLAlchemyError as err: - _LOGGER.error( - "Couldn't connect using %s DB_URL: %s", - redact_credentials(db_url), - redact_credentials(str(err)), + instance = get_instance(hass) + sessmaker: scoped_session | None + if use_database_executor := (db_url == instance.db_url): + assert instance.engine is not None + sessmaker = scoped_session(sessionmaker(bind=instance.engine, future=True)) + elif not ( + sessmaker := await hass.async_add_executor_job( + _validate_and_get_session_maker_for_db_url, db_url ) + ): return - finally: - if sess: - sess.close() # MSSQL uses TOP and not LIMIT if not ("LIMIT" in query_str.upper() or "SELECT TOP" in query_str.upper()): @@ -162,8 +155,6 @@ async def async_setup_sensor( else: query_str = query_str.replace(";", "") + " LIMIT 1;" - use_database_executor = db_url == get_instance(hass).db_url - async_add_entities( [ SQLSensor( @@ -184,6 +175,32 @@ async def async_setup_sensor( ) +def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | None: + """Validate the db_url and return a session maker. + + This does I/O and should be run in the executor. + """ + try: + engine = sqlalchemy.create_engine(db_url, future=True) + sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) + # Run a dummy query just to test the db_url + sess: Session = sessmaker() + sess.execute(sqlalchemy.text("SELECT 1;")) + + except SQLAlchemyError as err: + _LOGGER.error( + "Couldn't connect using %s DB_URL: %s", + redact_credentials(db_url), + redact_credentials(str(err)), + ) + return None + else: + return sessmaker + finally: + if sess: + sess.close() + + class SQLSensor(SensorEntity): """Representation of an SQL sensor.""" diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 400e3056d5..426dd9e196 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from typing import Any from unittest.mock import patch import pytest @@ -193,14 +194,19 @@ async def test_invalid_url_on_update( "column": "value", "name": "count_tables", } - await init_integration(hass, config) + + class MockSession: + """Mock session.""" + + def execute(self, query: Any) -> None: + """Execute the query.""" + raise SQLAlchemyError("sqlite://homeassistant:hunter2@homeassistant.local") with patch( - "homeassistant.components.sql.sensor.sqlalchemy.engine.cursor.CursorResult", - side_effect=SQLAlchemyError( - "sqlite://homeassistant:hunter2@homeassistant.local" - ), + "homeassistant.components.sql.sensor.scoped_session", + return_value=MockSession, ): + await init_integration(hass, config) async_fire_time_changed( hass, dt.utcnow() + timedelta(minutes=1), From c3717f8182d0eb7e176efc08ccb5be61d91b9a27 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Mar 2023 22:41:17 -0400 Subject: [PATCH 0171/1165] Clean up voice assistant integration (#90239) * Clean up voice assistant * Reinstate auto-removed imports * Resample STT audio from 44.1Khz to 16Khz * Energy based VAD for prototyping --------- Co-authored-by: Michael Hansen --- homeassistant/components/cloud/stt.py | 6 +- .../components/voice_assistant/pipeline.py | 150 ++++++------ .../voice_assistant/websocket_api.py | 62 ++++- .../snapshots/test_websocket.ambr | 210 +++++++++++++++++ .../voice_assistant/test_websocket.py | 216 +++++------------- 5 files changed, 407 insertions(+), 237 deletions(-) create mode 100644 tests/components/voice_assistant/snapshots/test_websocket.ambr diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index bdce055c3c..13062db57d 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import AsyncIterable +import logging from hass_nabucasa import Cloud from hass_nabucasa.voice import VoiceError @@ -20,6 +21,8 @@ from homeassistant.components.stt import ( from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + SUPPORT_LANGUAGES = [ "da-DK", "de-DE", @@ -102,7 +105,8 @@ class CloudProvider(Provider): result = await self.cloud.voice.process_stt( stream, content, metadata.language ) - except VoiceError: + except VoiceError as err: + _LOGGER.debug("Voice error: %s", err) return SpeechResult(None, SpeechResultState.ERROR) # Return Speech as Text diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 0070154bd4..806a603f5e 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -150,6 +150,7 @@ class PipelineRun: end_stage: PipelineStage event_callback: Callable[[PipelineEvent], None] language: str = None # type: ignore[assignment] + runner_data: Any | None = None def __post_init__(self): """Set language for pipeline.""" @@ -163,15 +164,14 @@ class PipelineRun: def start(self): """Emit run start event.""" - self.event_callback( - PipelineEvent( - PipelineEventType.RUN_START, - { - "pipeline": self.pipeline.name, - "language": self.language, - }, - ) - ) + data = { + "pipeline": self.pipeline.name, + "language": self.language, + } + if self.runner_data is not None: + data["runner_data"] = self.runner_data + + self.event_callback(PipelineEvent(PipelineEventType.RUN_START, data)) def end(self): """Emit run end event.""" @@ -200,41 +200,45 @@ class PipelineRun: try: # Load provider - stt_provider = stt.async_get_provider(self.hass, self.pipeline.stt_engine) + stt_provider: stt.Provider = stt.async_get_provider( + self.hass, self.pipeline.stt_engine + ) assert stt_provider is not None except Exception as src_error: - stt_error = SpeechToTextError( + _LOGGER.exception("No speech to text provider for %s", engine) + raise SpeechToTextError( code="stt-provider-missing", message=f"No speech to text provider for: {engine}", + ) from src_error + + if not stt_provider.check_metadata(metadata): + raise SpeechToTextError( + code="stt-provider-unsupported-metadata", + message=f"Provider {engine} does not support input speech to text metadata", ) - _LOGGER.exception(stt_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": stt_error.code, "message": stt_error.message}, - ) - ) - raise stt_error from src_error try: # Transcribe audio stream result = await stt_provider.async_process_audio_stream(metadata, stream) - assert (result.text is not None) and ( - result.result == stt.SpeechResultState.SUCCESS - ) except Exception as src_error: - stt_error = SpeechToTextError( + _LOGGER.exception("Unexpected error during speech to text") + raise SpeechToTextError( code="stt-stream-failed", message="Unexpected error during speech to text", + ) from src_error + + _LOGGER.debug("speech-to-text result %s", result) + + if result.result != stt.SpeechResultState.SUCCESS: + raise SpeechToTextError( + code="stt-stream-failed", + message="Speech to text failed", ) - _LOGGER.exception(stt_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": stt_error.code, "message": stt_error.message}, - ) + + if not result.text: + raise SpeechToTextError( + code="stt-no-text-recognized", message="No text recognized" ) - raise stt_error from src_error self.event_callback( PipelineEvent( @@ -273,18 +277,13 @@ class PipelineRun: agent_id=self.pipeline.conversation_engine, ) except Exception as src_error: - intent_error = IntentRecognitionError( + _LOGGER.exception("Unexpected error during intent recognition") + raise IntentRecognitionError( code="intent-failed", message="Unexpected error during intent recognition", - ) - _LOGGER.exception(intent_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": intent_error.code, "message": intent_error.message}, - ) - ) - raise intent_error from src_error + ) from src_error + + _LOGGER.debug("conversation result %s", conversation_result) self.event_callback( PipelineEvent( @@ -320,18 +319,13 @@ class PipelineRun: ), ) except Exception as src_error: - tts_error = TextToSpeechError( + _LOGGER.exception("Unexpected error during text to speech") + raise TextToSpeechError( code="tts-failed", message="Unexpected error during text to speech", - ) - _LOGGER.exception(tts_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": tts_error.code, "message": tts_error.message}, - ) - ) - raise tts_error from src_error + ) from src_error + + _LOGGER.debug("TTS result %s", tts_media) self.event_callback( PipelineEvent( @@ -377,31 +371,41 @@ class PipelineInput: run.start() current_stage = run.start_stage - # Speech to text - intent_input = self.intent_input - if current_stage == PipelineStage.STT: - assert self.stt_metadata is not None - assert self.stt_stream is not None - intent_input = await run.speech_to_text( - self.stt_metadata, - self.stt_stream, - ) - current_stage = PipelineStage.INTENT - - if run.end_stage != PipelineStage.STT: - tts_input = self.tts_input - - if current_stage == PipelineStage.INTENT: - assert intent_input is not None - tts_input = await run.recognize_intent( - intent_input, self.conversation_id + try: + # Speech to text + intent_input = self.intent_input + if current_stage == PipelineStage.STT: + assert self.stt_metadata is not None + assert self.stt_stream is not None + intent_input = await run.speech_to_text( + self.stt_metadata, + self.stt_stream, ) - current_stage = PipelineStage.TTS + current_stage = PipelineStage.INTENT - if run.end_stage != PipelineStage.INTENT: - if current_stage == PipelineStage.TTS: - assert tts_input is not None - await run.text_to_speech(tts_input) + if run.end_stage != PipelineStage.STT: + tts_input = self.tts_input + + if current_stage == PipelineStage.INTENT: + assert intent_input is not None + tts_input = await run.recognize_intent( + intent_input, self.conversation_id + ) + current_stage = PipelineStage.TTS + + if run.end_stage != PipelineStage.INTENT: + if current_stage == PipelineStage.TTS: + assert tts_input is not None + await run.text_to_speech(tts_input) + + except PipelineError as err: + run.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": err.code, "message": err.message}, + ) + ) + return run.end() diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index cc4799f13e..28cafb7a35 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -1,5 +1,6 @@ """Voice Assistant Websocket API.""" import asyncio +import audioop from collections.abc import Callable import logging from typing import Any @@ -12,6 +13,8 @@ from homeassistant.core import HomeAssistant, callback from .pipeline import ( DEFAULT_TIMEOUT, PipelineError, + PipelineEvent, + PipelineEventType, PipelineInput, PipelineRun, PipelineStage, @@ -20,6 +23,10 @@ from .pipeline import ( _LOGGER = logging.getLogger(__name__) +_VAD_ENERGY_THRESHOLD = 1000 +_VAD_SPEECH_FRAMES = 25 +_VAD_SILENCE_FRAMES = 25 + @callback def async_register_websocket_api(hass: HomeAssistant) -> None: @@ -27,6 +34,17 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_run) +def _get_debiased_energy(audio_data: bytes, width: int = 2) -> float: + """Compute RMS of debiased audio.""" + energy = -audioop.rms(audio_data, width) + energy_bytes = bytes([energy & 0xFF, (energy >> 8) & 0xFF]) + debiased_energy = audioop.rms( + audioop.add(audio_data, energy_bytes * (len(audio_data) // width), width), width + ) + + return debiased_energy + + @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", @@ -49,6 +67,11 @@ async def websocket_run( ) -> None: """Run a pipeline.""" language = msg.get("language", hass.config.language) + + # Temporary workaround for language codes + if language == "en": + language = "en-US" + pipeline_id = msg.get("pipeline") pipeline = async_get_pipeline( hass, @@ -79,8 +102,32 @@ async def websocket_run( audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue() async def stt_stream(): + state = None + speech_count = 0 + in_voice_command = False + # Yield until we receive an empty chunk while chunk := await audio_queue.get(): + chunk, state = audioop.ratecv(chunk, 2, 1, 44100, 16000, state) + is_speech = _get_debiased_energy(chunk) > _VAD_ENERGY_THRESHOLD + + if in_voice_command: + if is_speech: + speech_count += 1 + else: + speech_count -= 1 + + if speech_count <= -_VAD_SILENCE_FRAMES: + _LOGGER.info("Voice command stopped") + break + else: + if is_speech: + speech_count += 1 + + if speech_count >= _VAD_SPEECH_FRAMES: + in_voice_command = True + _LOGGER.info("Voice command started") + yield chunk def handle_binary(_hass, _connection, data: bytes): @@ -119,6 +166,9 @@ async def websocket_run( event_callback=lambda event: connection.send_event( msg["id"], event.as_dict() ), + runner_data={ + "stt_binary_handler_id": handler_id, + }, ), timeout=timeout, ) @@ -130,16 +180,20 @@ async def websocket_run( # Confirm subscription connection.send_result(msg["id"]) - if handler_id is not None: - # Send handler id to client - connection.send_event(msg["id"], {"handler_id": handler_id}) - try: # Task contains a timeout await run_task except PipelineError as error: # Report more specific error when possible connection.send_error(msg["id"], error.code, error.message) + except asyncio.TimeoutError: + connection.send_event( + msg["id"], + PipelineEvent( + PipelineEventType.ERROR, + {"code": "timeout", "message": "Timeout running pipeline"}, + ), + ) finally: if unregister_handler is not None: # Unregister binary handler diff --git a/tests/components/voice_assistant/snapshots/test_websocket.ambr b/tests/components/voice_assistant/snapshots/test_websocket.ambr new file mode 100644 index 0000000000..07934df6c4 --- /dev/null +++ b/tests/components/voice_assistant/snapshots/test_websocket.ambr @@ -0,0 +1,210 @@ +# serializer version: 1 +# name: test_audio_pipeline + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + }), + }) +# --- +# name: test_audio_pipeline.1 + dict({ + 'engine': 'default', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline.2 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_audio_pipeline.3 + dict({ + 'engine': 'default', + 'intent_input': 'test transcript', + }) +# --- +# name: test_audio_pipeline.4 + dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en-US', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }) +# --- +# name: test_audio_pipeline.5 + dict({ + 'engine': 'default', + 'tts_input': "Sorry, I couldn't understand that", + }) +# --- +# name: test_audio_pipeline.6 + dict({ + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_demo.mp3', + }), + }) +# --- +# name: test_intent_failed + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_intent_failed.1 + dict({ + 'engine': 'default', + 'intent_input': 'Are the lights on?', + }) +# --- +# name: test_intent_timeout + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_intent_timeout.1 + dict({ + 'engine': 'default', + 'intent_input': 'Are the lights on?', + }) +# --- +# name: test_intent_timeout.2 + dict({ + 'code': 'timeout', + 'message': 'Timeout running pipeline', + }) +# --- +# name: test_stt_provider_missing + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + }), + }) +# --- +# name: test_stt_provider_missing.1 + dict({ + 'engine': 'default', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_stt_stream_failed + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + }), + }) +# --- +# name: test_stt_stream_failed.1 + dict({ + 'engine': 'default', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_text_only_pipeline + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_text_only_pipeline.1 + dict({ + 'engine': 'default', + 'intent_input': 'Are the lights on?', + }) +# --- +# name: test_text_only_pipeline.2 + dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en-US', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }) +# --- +# name: test_text_pipeline_timeout + dict({ + 'code': 'timeout', + 'message': 'Timeout running pipeline', + }) +# --- +# name: test_tts_failed + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_tts_failed.1 + dict({ + 'engine': 'default', + 'tts_input': 'Lights are on.', + }) +# --- diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index a1ba8b5f7c..f02122a3e7 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -4,6 +4,7 @@ from collections.abc import AsyncIterable from unittest.mock import MagicMock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import stt from homeassistant.core import HomeAssistant @@ -29,7 +30,7 @@ class MockSttProvider(stt.Provider): @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - return [self.hass.config.language] + return ["en-US"] @property def supported_formats(self) -> list[stt.AudioFormats]: @@ -64,7 +65,11 @@ class MockSttProvider(stt.Provider): @pytest.fixture(autouse=True) -async def init_components(hass): +async def init_components( + hass: HomeAssistant, + mock_get_cache_files, # noqa: F811 + mock_init_cache_dir, # noqa: F811 +): """Initialize relevant components with empty configs.""" assert await async_setup_component(hass, "media_source", {}) assert await async_setup_component( @@ -93,6 +98,7 @@ async def init_components(hass): async def test_text_only_pipeline( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with text input (no STT/TTS).""" client = await hass_ws_client(hass) @@ -114,38 +120,16 @@ async def test_text_only_pipeline( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # intent msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": "Are the lights on?", - } + assert msg["event"]["data"] == snapshot msg = await client.receive_json() assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == { - "intent_output": { - "response": { - "speech": { - "plain": { - "speech": "Sorry, I couldn't understand that", - "extra_data": None, - } - }, - "card": {}, - "language": "en", - "response_type": "error", - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } - } + assert msg["event"]["data"] == snapshot # run end msg = await client.receive_json() @@ -154,8 +138,7 @@ async def test_text_only_pipeline( async def test_audio_pipeline( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion ) -> None: """Test events from a pipeline run with audio input/output.""" client = await hass_ws_client(hass) @@ -173,86 +156,40 @@ async def test_audio_pipeline( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # stt msg = await client.receive_json() assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == { - "engine": "default", - "metadata": { - "bit_rate": 16, - "channel": 1, - "codec": "pcm", - "format": "wav", - "language": "en", - "sample_rate": 16000, - }, - } + assert msg["event"]["data"] == snapshot # End of audio stream (handler id + empty payload) await client.send_bytes(b"1") msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" - assert msg["event"]["data"] == { - "stt_output": {"text": _TRANSCRIPT}, - } + assert msg["event"]["data"] == snapshot # intent msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": _TRANSCRIPT, - } + assert msg["event"]["data"] == snapshot msg = await client.receive_json() assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == { - "intent_output": { - "response": { - "speech": { - "plain": { - "speech": "Sorry, I couldn't understand that", - "extra_data": None, - } - }, - "card": {}, - "language": "en", - "response_type": "error", - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } - } + assert msg["event"]["data"] == snapshot # text to speech msg = await client.receive_json() assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == { - "engine": "default", - "tts_input": "Sorry, I couldn't understand that", - } + assert msg["event"]["data"] == snapshot msg = await client.receive_json() assert msg["event"]["type"] == "tts-end" - assert msg["event"]["data"] == { - "tts_output": { - "url": f"/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_{hass.config.language}_-_demo.mp3", - "mime_type": "audio/mpeg", - }, - } + assert msg["event"]["data"] == snapshot # run end msg = await client.receive_json() @@ -261,7 +198,10 @@ async def test_audio_pipeline( async def test_intent_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test partial pipeline run with conversation agent timeout.""" client = await hass_ws_client(hass) @@ -291,27 +231,24 @@ async def test_intent_timeout( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # intent msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": "Are the lights on?", - } + assert msg["event"]["data"] == snapshot # timeout error msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "timeout" + assert msg["event"]["type"] == "error" + assert msg["event"]["data"] == snapshot async def test_text_pipeline_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test text-only pipeline run with immediate timeout.""" client = await hass_ws_client(hass) @@ -340,12 +277,15 @@ async def test_text_pipeline_timeout( # timeout error msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "timeout" + assert msg["event"]["type"] == "error" + assert msg["event"]["data"] == snapshot async def test_intent_failed( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test text-only pipeline run with conversation agent error.""" client = await hass_ws_client(hass) @@ -371,18 +311,12 @@ async def test_intent_failed( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # intent start msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": "Are the lights on?", - } + assert msg["event"]["data"] == snapshot # intent error msg = await client.receive_json() @@ -391,7 +325,10 @@ async def test_intent_failed( async def test_audio_pipeline_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test audio pipeline run with immediate timeout.""" client = await hass_ws_client(hass) @@ -417,19 +354,16 @@ async def test_audio_pipeline_timeout( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # timeout error msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "timeout" + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "timeout" async def test_stt_provider_missing( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with a non-existent STT provider.""" with patch( @@ -451,32 +385,15 @@ async def test_stt_provider_missing( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # stt msg = await client.receive_json() assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == { - "engine": "default", - "metadata": { - "bit_rate": 16, - "channel": 1, - "codec": "pcm", - "format": "wav", - "language": "en", - "sample_rate": 16000, - }, - } + assert msg["event"]["data"] == snapshot # End of audio stream (handler id + empty payload) await client.send_bytes(b"1") @@ -490,6 +407,7 @@ async def test_stt_provider_missing( async def test_stt_stream_failed( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with a non-existent STT provider.""" with patch( @@ -511,32 +429,15 @@ async def test_stt_stream_failed( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # stt msg = await client.receive_json() assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == { - "engine": "default", - "metadata": { - "bit_rate": 16, - "channel": 1, - "codec": "pcm", - "format": "wav", - "language": "en", - "sample_rate": 16000, - }, - } + assert msg["event"]["data"] == snapshot # End of audio stream (handler id + empty payload) await client.send_bytes(b"1") @@ -548,7 +449,10 @@ async def test_stt_stream_failed( async def test_tts_failed( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test pipeline run with text to speech error.""" client = await hass_ws_client(hass) @@ -574,18 +478,12 @@ async def test_tts_failed( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # tts start msg = await client.receive_json() assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == { - "engine": "default", - "tts_input": "Lights are on.", - } + assert msg["event"]["data"] == snapshot # tts error msg = await client.receive_json() From 624860da0e9a49381571ef91788b863181387d0b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 27 Mar 2023 08:05:30 +0200 Subject: [PATCH 0172/1165] Remove deprecated platform yaml in Scrape (#90272) * Deprecate platform yaml * typing * DiscoveryInfoType --- homeassistant/components/scrape/sensor.py | 89 ++------------------ homeassistant/components/scrape/strings.json | 6 -- tests/components/scrape/__init__.py | 54 +----------- tests/components/scrape/test_sensor.py | 67 +-------------- 4 files changed, 8 insertions(+), 208 deletions(-) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 22184a17b8..5ddd6c48e4 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -1,44 +1,23 @@ """Support for getting data from websites with scraping.""" from __future__ import annotations -from datetime import timedelta import logging -from typing import Any +from typing import Any, cast import voluptuous as vol -from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - DEVICE_CLASSES_SCHEMA, - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - STATE_CLASSES_SCHEMA, - SensorDeviceClass, -) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ATTRIBUTE, - CONF_AUTHENTICATION, - CONF_DEVICE_CLASS, - CONF_HEADERS, CONF_NAME, - CONF_PASSWORD, - CONF_RESOURCE, - CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, - CONF_UNIT_OF_MEASUREMENT, - CONF_USERNAME, CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ( TEMPLATE_SENSOR_BASE_SCHEMA, @@ -47,43 +26,11 @@ from homeassistant.helpers.template_entity import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_INDEX, - CONF_SELECT, - DEFAULT_NAME, - DEFAULT_SCAN_INTERVAL, - DEFAULT_VERIFY_SSL, - DOMAIN, -) +from .const import CONF_INDEX, CONF_SELECT, DOMAIN from .coordinator import ScrapeCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - # Linked to the loading of the page (can be linked to RestData) - vol.Optional(CONF_AUTHENTICATION): vol.In( - [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] - ), - vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), - vol.Optional(CONF_PASSWORD): cv.string, - vol.Required(CONF_RESOURCE): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - # Linked to the parsing of the page (specific to scrape) - vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(CONF_INDEX, default=0): cv.positive_int, - vol.Required(CONF_SELECT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - # Linked to the sensor definition (can be linked to TemplateSensor) - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -) - async def async_setup_platform( hass: HomeAssistant, @@ -92,33 +39,9 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Web scrape sensor.""" - coordinator: ScrapeCoordinator - sensors_config: list[ConfigType] - if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "moved_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="moved_yaml", - ) - resource_config = vol.Schema(RESOURCE_SCHEMA, extra=vol.REMOVE_EXTRA)(config) - rest = create_rest_data_from_config(hass, resource_config) - - scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - coordinator = ScrapeCoordinator(hass, rest, scan_interval) - - sensors_config = [ - vol.Schema(TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA)( - config - ) - ] - - else: - coordinator = discovery_info["coordinator"] - sensors_config = discovery_info["configs"] + discovery_info = cast(DiscoveryInfoType, discovery_info) + coordinator: ScrapeCoordinator = discovery_info["coordinator"] + sensors_config: list[ConfigType] = discovery_info["configs"] await coordinator.async_refresh() if coordinator.data is None: diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 907aa2a9df..061518cb1d 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -121,11 +121,5 @@ } } } - }, - "issues": { - "moved_yaml": { - "title": "The Scrape YAML configuration has been moved", - "description": "Configuring Scrape using YAML has been moved to integration key.\n\nYour existing YAML configuration will be working for 2 more versions.\n\nMigrate your YAML configuration to the integration key according to the documentation." - } } } diff --git a/tests/components/scrape/__init__.py b/tests/components/scrape/__init__.py index 1bf3040513..3d57970a52 100644 --- a/tests/components/scrape/__init__.py +++ b/tests/components/scrape/__init__.py @@ -29,65 +29,13 @@ def return_integration_config( return config -def return_config( - select, - name, - *, - attribute=None, - index=None, - template=None, - uom=None, - device_class=None, - state_class=None, - authentication=None, - username=None, - password=None, - headers=None, - unique_id=None, - remove_platform=False, -) -> dict[str, dict[str, Any]]: - """Return config.""" - config = { - "platform": "scrape", - "resource": "https://www.home-assistant.io", - "select": select, - "name": name, - "index": 0, - "verify_ssl": True, - } - if remove_platform: - config.pop("platform") - if attribute: - config["attribute"] = attribute - if index: - config["index"] = index - if template: - config["value_template"] = template - if uom: - config["unit_of_measurement"] = uom - if device_class: - config["device_class"] = device_class - if state_class: - config["state_class"] = state_class - if authentication: - config["authentication"] = authentication - if username: - config["username"] = username - config["password"] = password - if headers: - config["headers"] = headers - if unique_id: - config["unique_id"] = unique_id - return config - - class MockRestData: """Mock RestData.""" def __init__( self, payload, - ): + ) -> None: """Init RestDataMock.""" self.data: str | None = None self.payload = payload diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index bc13e01d25..44c264520d 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -9,7 +9,6 @@ import pytest from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL from homeassistant.components.sensor import ( CONF_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, ) @@ -24,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from . import MockRestData, return_config, return_integration_config +from . import MockRestData, return_integration_config from tests.common import MockConfigEntry, async_fire_time_changed @@ -53,70 +52,6 @@ async def test_scrape_sensor(hass: HomeAssistant) -> None: assert state.state == "Current Version: 2021.12.10" -async def test_scrape_sensor_platform_yaml(hass: HomeAssistant) -> None: - """Test Scrape sensor load from sensor platform.""" - config = { - SENSOR_DOMAIN: [ - return_config( - select=".return", - name="Auth page", - username="user@secret.com", - password="12345678", - authentication="digest", - ), - return_config( - select=".return", - name="Auth page2", - username="user@secret.com", - password="12345678", - template="{{value}}", - ), - ] - } - - mocker = MockRestData("test_scrape_sensor_authentication") - with patch( - "homeassistant.components.rest.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, SENSOR_DOMAIN, config) - await hass.async_block_till_done() - - state = hass.states.get("sensor.auth_page") - assert state.state == "secret text" - state2 = hass.states.get("sensor.auth_page2") - assert state2.state == "secret text" - - -async def test_scrape_sensor_platform_yaml_no_data( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test Scrape sensor load from sensor platform fetching no data.""" - config = { - SENSOR_DOMAIN: [ - return_config( - select=".return", - name="Auth page", - username="user@secret.com", - password="12345678", - authentication="digest", - ), - ] - } - - mocker = MockRestData("test_scrape_sensor_no_data") - with patch( - "homeassistant.components.rest.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, SENSOR_DOMAIN, config) - await hass.async_block_till_done() - - state = hass.states.get("sensor.auth_page") - assert not state - assert "Platform scrape not ready yet: None; Retrying in background" in caplog.text - - async def test_scrape_sensor_value_template(hass: HomeAssistant) -> None: """Test Scrape sensor with value template.""" config = { From a773c37190cba72e4fc772fc7611225f291f69d2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Mar 2023 08:33:46 +0200 Subject: [PATCH 0173/1165] Cleanup name assignment imap sensor (#90306) --- homeassistant/components/imap/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 4dc0c0fffb..776abc174a 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_USERNAME +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -41,10 +41,6 @@ class ImapSensor( ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - # To be removed when YAML import is removed - if CONF_NAME in coordinator.config_entry.data: - self._attr_name = coordinator.config_entry.data[CONF_NAME] - self._attr_has_entity_name = False self._attr_unique_id = f"{coordinator.config_entry.entry_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, From 6a5c05e7d2290ebc5d41d0abbe4be0ece1522e3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 21:34:28 -1000 Subject: [PATCH 0174/1165] Add support for clearing the on device GATT cache to esphome (#90318) --- .../components/esphome/bluetooth/client.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index c332fc3441..914021b467 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -44,6 +44,7 @@ CCCD_INDICATE_BYTES = b"\x02\x00" MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE = 3 MIN_BLUETOOTH_PROXY_HAS_PAIRING = 4 +MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE = 5 DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) @@ -518,10 +519,28 @@ class ESPHomeClient(BaseBleakClient): raise BleakError(f"Characteristic {char_specifier} was not found!") return characteristic - async def clear_cache(self) -> None: + @api_error_as_bleak_error + async def clear_cache(self) -> bool: """Clear the GATT cache.""" self.domain_data.clear_gatt_services_cache(self._address_as_int) self.domain_data.clear_gatt_mtu_cache(self._address_as_int) + if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE: + _LOGGER.warning( + "On device cache clear is not available with ESPHome Bluetooth version %s, " + "version %s is needed; Only memory cache will be cleared", + self._connection_version, + MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE, + ) + return True + response = await self._client.bluetooth_device_clear_cache(self._address_as_int) + if response.success: + return True + _LOGGER.error( + "Clear cache failed with %s failed due to error: %s", + self.address, + response.error, + ) + return False @verify_connected @api_error_as_bleak_error From 164482dc089fd649d2862e57d425922fb16ea7eb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 10:00:41 +0200 Subject: [PATCH 0175/1165] Use lambda in gree switch (#90316) --- homeassistant/components/gree/switch.py | 77 +++++++++++++++++++++---- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 0ac7406713..01f98b996d 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -1,8 +1,12 @@ """Support for interface with a Gree climate systems.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any, cast +from greeclimate.device import Device + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -16,25 +20,77 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN from .entity import GreeEntity -GREE_SWITCHES: tuple[SwitchEntityDescription, ...] = ( - SwitchEntityDescription( + +@dataclass +class GreeRequiredKeysMixin: + """Mixin for required keys.""" + + get_value_fn: Callable[[Device], bool] + set_value_fn: Callable[[Device, bool], None] + + +@dataclass +class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): + """Describes Gree switch entity.""" + + +def _set_light(device: Device, value: bool) -> None: + """Typed helper to set device light property.""" + device.light = value + + +def _set_quiet(device: Device, value: bool) -> None: + """Typed helper to set device quiet property.""" + device.quiet = value + + +def _set_fresh_air(device: Device, value: bool) -> None: + """Typed helper to set device fresh_air property.""" + device.fresh_air = value + + +def _set_xfan(device: Device, value: bool) -> None: + """Typed helper to set device xfan property.""" + device.xfan = value + + +def _set_anion(device: Device, value: bool) -> None: + """Typed helper to set device anion property.""" + device.anion = value + + +GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( + GreeSwitchEntityDescription( icon="mdi:lightbulb", name="Panel Light", key="light", + get_value_fn=lambda d: d.light, + set_value_fn=_set_light, ), - SwitchEntityDescription( + GreeSwitchEntityDescription( name="Quiet", key="quiet", + get_value_fn=lambda d: d.quiet, + set_value_fn=_set_quiet, ), - SwitchEntityDescription( + GreeSwitchEntityDescription( name="Fresh Air", key="fresh_air", + get_value_fn=lambda d: d.fresh_air, + set_value_fn=_set_fresh_air, ), - SwitchEntityDescription(name="XFan", key="xfan"), - SwitchEntityDescription( + GreeSwitchEntityDescription( + name="XFan", + key="xfan", + get_value_fn=lambda d: d.xfan, + set_value_fn=_set_xfan, + ), + GreeSwitchEntityDescription( icon="mdi:pine-tree", name="Health mode", key="anion", + get_value_fn=lambda d: d.anion, + set_value_fn=_set_anion, entity_registry_enabled_default=False, ), ) @@ -68,8 +124,9 @@ class GreeSwitch(GreeEntity, SwitchEntity): """Generic Gree switch entity.""" _attr_device_class = SwitchDeviceClass.SWITCH + entity_description: GreeSwitchEntityDescription - def __init__(self, coordinator, description: SwitchEntityDescription) -> None: + def __init__(self, coordinator, description: GreeSwitchEntityDescription) -> None: """Initialize the Gree device.""" self.entity_description = description @@ -78,16 +135,16 @@ class GreeSwitch(GreeEntity, SwitchEntity): @property def is_on(self) -> bool: """Return if the state is turned on.""" - return getattr(self.coordinator.device, self.entity_description.key) + return self.entity_description.get_value_fn(self.coordinator.device) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - setattr(self.coordinator.device, self.entity_description.key, True) + self.entity_description.set_value_fn(self.coordinator.device, True) await self.coordinator.push_state_update() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - setattr(self.coordinator.device, self.entity_description.key, False) + self.entity_description.set_value_fn(self.coordinator.device, False) await self.coordinator.push_state_update() self.async_write_ha_state() From 8c519e1abb1468256c028a04c0c68f84d5cee68a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 10:01:39 +0200 Subject: [PATCH 0176/1165] Use SnapshotAssertion in gree climate tests (#90339) --- tests/components/gree/common.py | 7 +- .../gree/snapshots/test_climate.ambr | 118 ++++++++++++++++++ tests/components/gree/test_climate.py | 37 +++--- 3 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 tests/components/gree/snapshots/test_climate.ambr diff --git a/tests/components/gree/common.py b/tests/components/gree/common.py index cd8a2d6ee2..aa88688486 100644 --- a/tests/components/gree/common.py +++ b/tests/components/gree/common.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock from greeclimate.discovery import Listener from homeassistant.components.gree.const import DISCOVERY_TIMEOUT, DOMAIN as GREE_DOMAIN +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -90,8 +91,10 @@ def build_device_mock(name="fake-device-1", ipAddress="1.1.1.1", mac="aabbcc1122 return mock -async def async_setup_gree(hass): +async def async_setup_gree(hass: HomeAssistant) -> MockConfigEntry: """Set up the gree platform.""" - MockConfigEntry(domain=GREE_DOMAIN).add_to_hass(hass) + entry = MockConfigEntry(domain=GREE_DOMAIN) + entry.add_to_hass(hass) await async_setup_component(hass, GREE_DOMAIN, {GREE_DOMAIN: {"climate": {}}}) await hass.async_block_till_done() + return entry diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr new file mode 100644 index 0000000000..f1479cad3d --- /dev/null +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -0,0 +1,118 @@ +# serializer version: 1 +# name: test_entity_states + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 25, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'low', + 'medium low', + 'medium', + 'medium high', + 'high', + ]), + 'friendly_name': 'fake-device-1', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 8, + 'preset_mode': 'none', + 'preset_modes': list([ + 'eco', + 'away', + 'boost', + 'none', + 'sleep', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + 'target_temp_step': 1, + 'temperature': 25, + }), + 'context': , + 'entity_id': 'climate.fake_device_1', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_registry_settings + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium low', + 'medium', + 'medium high', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 8, + 'preset_modes': list([ + 'eco', + 'away', + 'boost', + 'none', + 'sleep', + ]), + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fake_device_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake-device-1', + 'platform': 'gree', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'aabbcc112233', + 'unit_of_measurement': None, + }), + ]) +# --- diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 16b6d0cf3a..afed01c1a0 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -5,6 +5,7 @@ from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, patch from greeclimate.device import HorizontalSwing, VerticalSwing from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -31,15 +32,12 @@ from homeassistant.components.climate import ( SWING_HORIZONTAL, SWING_OFF, SWING_VERTICAL, - ClimateEntityFeature, HVACMode, ) from homeassistant.components.gree.climate import FAN_MODES_REVERSE, HVAC_MODES_REVERSE from homeassistant.components.gree.const import FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -47,6 +45,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .common import async_setup_gree, build_device_mock @@ -797,22 +796,20 @@ async def test_update_swing_mode( assert state.attributes.get(ATTR_SWING_MODE) == swing_mode -async def test_name(hass: HomeAssistant, discovery, device) -> None: - """Test for name property.""" - await async_setup_gree(hass) - state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake-device-1" - - -async def test_supported_features_with_turnon( - hass: HomeAssistant, discovery, device +@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +async def test_registry_settings( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: - """Test for supported_features property.""" + """Test for entity registry settings (unique_id).""" + entry = await async_setup_gree(hass) + + entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert entries == snapshot + + +@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +async def test_entity_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test for entity registry settings (unique_id).""" await async_setup_gree(hass) - state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_SUPPORTED_FEATURES] == ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - ) + states = hass.states.async_all() + assert states == snapshot From 94a52d5ccad9dcf1151b539481dd78abb7983e12 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:00:23 +0200 Subject: [PATCH 0177/1165] Adjust tts default_options type hints (#90053) * Adjust tts default_options type hints * Improve other components * Adjust * Revert component changes * Adjust get_tts_audio in amazon_polly --- homeassistant/components/amazon_polly/tts.py | 6 +++--- homeassistant/components/tts/__init__.py | 12 +++++++----- pylint/plugins/hass_enforce_type_hints.py | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 7e21b9ac60..97e0af7f18 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Final +from typing import Any, Final import boto3 import botocore @@ -166,8 +166,8 @@ class AmazonPollyProvider(Provider): def get_tts_audio( self, message: str, - language: str | None = None, - options: dict[str, str] | None = None, + language: str, + options: dict[str, Any] | None = None, ) -> TtsAudioType: """Request TTS file from Polly.""" if options is None or language is None: diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 39aedfe8cb..aa8864ad23 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import functools as ft import hashlib from http import HTTPStatus @@ -380,11 +381,12 @@ class SpeechManager: raise HomeAssistantError(f"Not supported language {language}") # Options - if provider.default_options and options: - merged_options = provider.default_options.copy() + if (default_options := provider.default_options) and options: + merged_options = dict(default_options) merged_options.update(options) options = merged_options - options = options or provider.default_options + if not options: + options = None if default_options is None else dict(default_options) if options is not None: supported_options = provider.supported_options or [] @@ -665,8 +667,8 @@ class Provider: return None @property - def default_options(self) -> dict[str, Any] | None: - """Return a dict include default options.""" + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" return None def get_tts_audio( diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 63cc5a1c6b..58bfd3b69b 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2366,7 +2366,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="default_options", - return_type=["dict[str, Any]", None], + return_type=["Mapping[str, Any]", None], ), TypeHintMatch( function_name="get_tts_audio", From 56293ad876884fb3c49ecf4d2e18cdcc82e95452 Mon Sep 17 00:00:00 2001 From: PatrickGlesner <34370149+PatrickGlesner@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:00:52 +0200 Subject: [PATCH 0178/1165] Revert "Fix NMBS IndexError" (#90346) --- homeassistant/components/nmbs/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index c3bcdb3553..b9a216875f 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -162,11 +162,7 @@ class NMBSLiveBoard(SensorEntity): """Set the state equal to the next departure.""" liveboard = self._api_client.get_liveboard(self._station) - if ( - liveboard is None - or not liveboard.get("departures") - or liveboard.get("number") == "0" - ): + if liveboard is None or not liveboard.get("departures"): return next_departure = liveboard["departures"]["departure"][0] From 97f8a3fdcd126814ede5594e586748823ec0c800 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 27 Mar 2023 11:04:03 +0200 Subject: [PATCH 0179/1165] Reolink add auto tracking entities (#90063) --- homeassistant/components/reolink/number.py | 54 +++++++++++++++++++ homeassistant/components/reolink/select.py | 13 ++++- homeassistant/components/reolink/strings.json | 7 +++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 4a221e2ca9..bb19974114 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -188,6 +188,60 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.quick_reply_time(ch), method=lambda api, ch, value: api.set_quick_reply(ch, time=int(value)), ), + ReolinkNumberEntityDescription( + key="auto_track_limit_left", + name="Auto track limit left", + icon="mdi:angle-acute", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=-1, + native_max_value=2700, + supported=lambda api, ch: api.supported(ch, "auto_track_limit"), + value=lambda api, ch: api.auto_track_limit_left(ch), + method=lambda api, ch, value: api.set_auto_track_limit(ch, left=int(value)), + ), + ReolinkNumberEntityDescription( + key="auto_track_limit_right", + name="Auto track limit right", + icon="mdi:angle-acute", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=-1, + native_max_value=2700, + supported=lambda api, ch: api.supported(ch, "auto_track_limit"), + value=lambda api, ch: api.auto_track_limit_right(ch), + method=lambda api, ch, value: api.set_auto_track_limit(ch, right=int(value)), + ), + ReolinkNumberEntityDescription( + key="auto_track_disappear_time", + name="Auto track disappear time", + icon="mdi:target-account", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=60, + supported=lambda api, ch: api.supported(ch, "auto_track_disappear_time"), + value=lambda api, ch: api.auto_track_disappear_time(ch), + method=lambda api, ch, value: api.set_auto_tracking( + ch, disappear_time=int(value) + ), + ), + ReolinkNumberEntityDescription( + key="auto_track_stop_time", + name="Auto track stop time", + icon="mdi:target-account", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=60, + supported=lambda api, ch: api.supported(ch, "auto_track_stop_time"), + value=lambda api, ch: api.auto_track_stop_time(ch), + method=lambda api, ch, value: api.set_auto_tracking(ch, stop_time=int(value)), + ), ) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index e18961c97d..a994b7d353 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from reolink_aio.api import DayNightEnum, Host, SpotlightModeEnum +from reolink_aio.api import DayNightEnum, Host, SpotlightModeEnum, TrackMethodEnum from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -79,6 +79,17 @@ SELECT_ENTITIES = ( ch, file_id=[k for k, v in api.quick_reply_dict(ch).items() if v == mess][0] ), ), + ReolinkSelectEntityDescription( + key="auto_track_method", + name="Auto track method", + icon="mdi:target-account", + translation_key="auto_track_method", + entity_category=EntityCategory.CONFIG, + get_options=[method.name for method in TrackMethodEnum], + supported=lambda api, ch: api.supported(ch, "auto_track_method"), + value=lambda api, ch: TrackMethodEnum(api.auto_track_method(ch)).name, + method=lambda api, ch, name: api.set_auto_tracking(ch, method=name), + ), ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 06b588a119..74759c12f9 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -72,6 +72,13 @@ "state": { "off": "Off" } + }, + "auto_track_method": { + "state": { + "digital": "Digital", + "digitalfirst": "Digital first", + "pantiltfirst": "Pan/tilt first" + } } } } From 0d5864682390e7f10d131d58697b3ef38cd4564a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 27 Mar 2023 11:11:38 +0200 Subject: [PATCH 0180/1165] Bump reolink-aio to 0.5.7 (#90344) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 7050ed61d5..95b180fc16 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.6"] + "requirements": ["reolink-aio==0.5.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 31e78c40c2..fafe16fb78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.6 +reolink-aio==0.5.7 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3be11d6cd6..93bd067383 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.6 +reolink-aio==0.5.7 # homeassistant.components.python_script restrictedpython==6.0 From 5b3c57ff1e954273b7e24d600697bcbd98321349 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Mar 2023 11:47:22 +0200 Subject: [PATCH 0181/1165] Add option flow for imap integration (#89914) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/imap/config_flow.py | 74 ++++++++++++- homeassistant/components/imap/strings.json | 18 +++ tests/components/imap/conftest.py | 14 +++ tests/components/imap/test_config_flow.py | 110 ++++++++++++++++--- 4 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 tests/components/imap/conftest.py diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index de1ac1e5d6..c855d099b4 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv @@ -36,6 +37,13 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FOLDER, default="INBOX"): str, + vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str, + } +) + async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: """Validate user input.""" @@ -80,9 +88,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match( { - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_FOLDER: user_input[CONF_FOLDER], - CONF_SEARCH: user_input[CONF_SEARCH], + key: user_input[key] + for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH) } ) @@ -128,3 +135,64 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlow(config_entry) + + +class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): + """Option flow handler.""" + + def _async_abort_entries_match( + self, match_dict: dict[str, Any] | None + ) -> dict[str, str]: + """Validate the user input against other config entries.""" + if match_dict is None: + return {} + + errors: dict[str, str] = {} + for entry in [ + entry + for entry in self.hass.config_entries.async_entries(DOMAIN) + if entry is not self.config_entry + ]: + if all(item in entry.data.items() for item in match_dict.items()): + errors["base"] = "already_configured" + break + return errors + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] = self._async_abort_entries_match( + { + CONF_SERVER: self._config_entry.data[CONF_SERVER], + CONF_USERNAME: self._config_entry.data[CONF_USERNAME], + CONF_FOLDER: user_input[CONF_FOLDER], + CONF_SEARCH: user_input[CONF_SEARCH], + } + if user_input + else None + ) + entry_data: dict[str, Any] = dict(self._config_entry.data) + if not errors and user_input is not None: + entry_data.update(user_input) + errors = await validate_input(entry_data) + if not errors: + self.hass.config_entries.async_update_entry( + self.config_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + return self.async_create_entry(data={}) + + schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data) + + return self.async_show_form(step_id="init", data_schema=schema, errors=errors) diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index bb03f82bb7..d104f591c6 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -31,5 +31,23 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "init": { + "data": { + "folder": "[%key:component::imap::config::step::user::data::folder%]", + "search": "[%key:component::imap::config::step::user::data::search%]" + } + } + }, + "error": { + "already_configured": "An entry with these folder and search options already exists", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_charset": "[%key:component::imap::config::error::invalid_charset%]", + "invalid_folder": "[%key:component::imap::config::error::invalid_folder%]", + "invalid_search": "[%key:component::imap::config::error::invalid_search%]" + } } } diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py new file mode 100644 index 0000000000..bc82cf57d8 --- /dev/null +++ b/tests/components/imap/conftest.py @@ -0,0 +1,14 @@ +"""Test the iamp config flow.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.imap.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 663637ff0b..20c9ddf893 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -1,11 +1,11 @@ """Test the imap config flow.""" import asyncio -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from aioimaplib import AioImapException import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.imap.const import ( CONF_CHARSET, CONF_FOLDER, @@ -29,8 +29,15 @@ MOCK_CONFIG = { "search": "UnSeen UnDeleted", } +MOCK_OPTIONS = { + "folder": "INBOX", + "search": "UnSeen UnDeleted", +} -async def test_form(hass: HomeAssistant) -> None: +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -40,10 +47,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_client: mock_client.return_value.search.return_value = ( "OK", [b""], @@ -184,10 +188,7 @@ async def test_form_invalid_search(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" ) as mock_client: - mock_client.return_value.search.return_value = ( - "BAD", - [b"Invalid search"], - ) + mock_client.return_value.search.return_value = ("BAD", [b"Invalid search"]) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG ) @@ -196,7 +197,7 @@ async def test_form_invalid_search(hass: HomeAssistant) -> None: assert result2["errors"] == {CONF_SEARCH: "invalid_search"} -async def test_reauth_success(hass: HomeAssistant) -> None: +async def test_reauth_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we can reauth.""" entry = MockConfigEntry( domain=DOMAIN, @@ -219,10 +220,7 @@ async def test_reauth_success(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_client: mock_client.return_value.search.return_value = ( "OK", [b""], @@ -310,3 +308,83 @@ async def test_reauth_failed_conn_error(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_options_form(hass: HomeAssistant) -> None: + """Test we show the options form.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS.copy() + new_config["folder"] = "INBOX.Notifications" + new_config["search"] = "UnSeen UnDeleted!!INVALID" + + # simulate initial search setup error + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("BAD", [b"Invalid search"]) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], new_config + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {CONF_SEARCH: "invalid_search"} + + new_config["search"] = "UnSeen UnDeleted" + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + new_config, + ) + await hass.async_block_till_done() + assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result3["data"] == {} + for key, value in new_config.items(): + assert entry.data[key] == value + + +async def test_key_options_in_options_form(hass: HomeAssistant) -> None: + """Test we cannot change options if that would cause duplicates.""" + + entry1 = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry1.add_to_hass(hass) + await hass.config_entries.async_setup(entry1.entry_id) + + config2 = MOCK_CONFIG.copy() + config2["folder"] = "INBOX.Notifications" + entry2 = MockConfigEntry(domain=DOMAIN, data=config2) + entry2.add_to_hass(hass) + await hass.config_entries.async_setup(entry2.entry_id) + + # Now try to set back the folder option of entry2 + # so that it conflicts with that of entry1 + result = await hass.config_entries.options.async_init(entry2.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS.copy() + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + new_config, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "already_configured"} From d9471fd01a3abdedc230c650f97e18e76f9c4a79 Mon Sep 17 00:00:00 2001 From: Joel Goguen Date: Mon, 27 Mar 2023 06:14:16 -0400 Subject: [PATCH 0182/1165] Bump python-holidays to 0.21.13 (#89724) Update Python holidays module to 0.21.13 python-holidays 0.19-0.21.13 adds support for new countries and enhances support for many currently supported countries. --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 4c10141406..c9299b21ce 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -11,5 +11,5 @@ "korean_lunar_calendar" ], "quality_scale": "internal", - "requirements": ["holidays==0.18.0"] + "requirements": ["holidays==0.21.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index fafe16fb78..134bc6f459 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -904,7 +904,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.18.0 +holidays==0.21.13 # homeassistant.components.frontend home-assistant-frontend==20230309.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 93bd067383..977c2896c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.18.0 +holidays==0.21.13 # homeassistant.components.frontend home-assistant-frontend==20230309.1 From c11a3881af58d681a27e1402d54de4e7963cd2e8 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 27 Mar 2023 06:25:04 -0400 Subject: [PATCH 0183/1165] Bump zwave-js-server-python to 0.47.0 (#90212) --- .../components/zwave_js/diagnostics.py | 4 ++-- .../components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 1 + tests/components/zwave_js/test_diagnostics.py | 11 +--------- tests/components/zwave_js/test_trigger.py | 20 +++++++++++++------ 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 50130fc263..acb87a239a 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -117,7 +117,8 @@ async def async_get_config_entry_diagnostics( handshake_msgs = msgs[:-1] network_state = msgs[-1] network_state["result"]["state"]["nodes"] = [ - redact_node_state(node) for node in network_state["result"]["state"]["nodes"] + redact_node_state(async_redact_data(node, KEYS_TO_REDACT)) + for node in network_state["result"]["state"]["nodes"] ] return {"messages": [*handshake_msgs, network_state]} @@ -136,7 +137,6 @@ async def async_get_device_diagnostics( entities = get_device_entities(hass, node, device) assert client.version node_state = redact_node_state(async_redact_data(node.data, KEYS_TO_REDACT)) - node_state["statistics"] = node.statistics.data return { "versionInfo": { "driverVersion": client.version.driver_version, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a21f7a6f30..0ad934103d 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.46.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 134bc6f459..cf3fbc10f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2728,7 +2728,7 @@ zigpy==0.53.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.46.0 +zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me zwave_me_ws==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 977c2896c0..f1a499c45d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1950,7 +1950,7 @@ zigpy-znp==0.9.3 zigpy==0.53.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.46.0 +zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me zwave_me_ws==0.3.1 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 43489be4cc..f8a7a68f13 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3201,6 +3201,7 @@ async def test_subscribe_log_updates( "multiline": False, "timestamp": "time", "label": "label", + "context": {"source": "config"}, }, ) client.driver.receive_event(event) diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index e3c144c4ac..773b799cd6 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -92,16 +92,7 @@ async def test_device_diagnostics( assert len(diagnostics_data["entities"]) == len( list(async_discover_node_values(multisensor_6, device, {device.id: set()})) ) - assert diagnostics_data["state"] == { - **multisensor_6.data, - "statistics": { - "commandsDroppedRX": 0, - "commandsDroppedTX": 0, - "commandsRX": 0, - "commandsTX": 0, - "timeoutResponse": 0, - }, - } + assert diagnostics_data["state"] == multisensor_6.data async def test_device_diagnostics_error(hass: HomeAssistant, integration) -> None: diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 9ba0080667..cbf68a55f5 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -601,7 +601,8 @@ async def test_zwave_js_event( }, ) - # Test that `node no event data filter` is triggered and `node event data filter` is not + # Test that `node no event data filter` is triggered and `node event data + # filter` is not event = Event( type="interview stage completed", data={ @@ -649,7 +650,8 @@ async def test_zwave_js_event( clear_events() - # Test that `controller no event data filter` is triggered and `controller event data filter` is not + # Test that `controller no event data filter` is triggered and `controller event + # data filter` is not event = Event( type="inclusion started", data={ @@ -672,7 +674,8 @@ async def test_zwave_js_event( clear_events() - # Test that both `controller no event data filter` and `controller event data filter` are triggered + # Test that both `controller no event data filter` and `controller event data + # filter`` are triggered event = Event( type="inclusion started", data={ @@ -695,7 +698,8 @@ async def test_zwave_js_event( clear_events() - # Test that `driver no event data filter` is triggered and `driver event data filter` is not + # Test that `driver no event data filter` is triggered and `driver event data + # filter` is not event = Event( type="logging", data={ @@ -711,6 +715,7 @@ async def test_zwave_js_event( "multiline": False, "timestamp": "time", "label": "label", + "context": {"source": "config"}, }, ) client.driver.receive_event(event) @@ -727,7 +732,8 @@ async def test_zwave_js_event( clear_events() - # Test that both `driver no event data filter` and `driver event data filter` are triggered + # Test that both `driver no event data filter` and `driver event data filter` + # are triggered event = Event( type="logging", data={ @@ -743,6 +749,7 @@ async def test_zwave_js_event( "multiline": False, "timestamp": "time", "label": "label", + "context": {"source": "config"}, }, ) client.driver.receive_event(event) @@ -862,7 +869,8 @@ async def test_zwave_js_event_bypass_dynamic_validation( }, ) - # Test that `node no event data filter` is triggered and `node event data filter` is not + # Test that `node no event data filter` is triggered and `node event data filter` + # is not event = Event( type="interview stage completed", data={ From 53de9dcdbc3c2a2119a84f72bd76f7e090c351d5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 13:09:42 +0200 Subject: [PATCH 0184/1165] Fix pylint plugin for binary websocket (#90351) --- pylint/plugins/hass_enforce_type_hints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 58bfd3b69b..0581569c2d 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2323,7 +2323,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="async_process_audio_stream", - arg_types={1: "SpeechMetadata", 2: "StreamReader"}, + arg_types={1: "SpeechMetadata", 2: "AsyncIterable[bytes]"}, return_type="SpeechResult", ), ], From c193402ba72e84fcca851c26dc0a764a6c8efa1a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 13:53:14 +0200 Subject: [PATCH 0185/1165] Remove incorrect ignore in pylint plugin (#90024) * Remove incorrect ignore_missing_annotations * Allow tuple[int, int] in hs_color * Adjust notify targets * Always check for return type inheritance * Adjust tests * Revert "Always check for return type inheritance" This reverts commit 3528742adf98edc6481f2c954c032ace881e1d6e. * Revert "Allow tuple[int, int] in hs_color" This reverts commit d51c1731eff3d59b9e94e7a7e914933ceaf8e34f. * Revert "Adjust notify targets" This reverts commit 4cba77a7309dc89980e29d6d5b9107d9e55f7070. --- pylint/plugins/hass_enforce_type_hints.py | 4 +--- tests/pylint/test_enforce_type_hints.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 0581569c2d..4fb471e214 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2974,9 +2974,7 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] if class_matches := _CLASS_MATCH.get(module_platform): self._class_matchers.extend(class_matches) - if not self.linter.config.ignore_missing_annotations and ( - property_matches := _INHERITANCE_MATCH.get(module_platform) - ): + if property_matches := _INHERITANCE_MATCH.get(module_platform): self._class_matchers.extend(property_matches) self._class_matchers.reverse() diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 9e8df452b6..b80d8f0144 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -574,7 +574,7 @@ def test_ignore_invalid_entity_properties( async def async_lock( self, **kwargs - ) -> bool: + ): pass """, "homeassistant.components.pylint_test.lock", From a91aef9d52bf64d15776ea8ce0f657e83f7b998e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Mar 2023 14:01:17 +0200 Subject: [PATCH 0186/1165] Rewrite tts tests (#90355) --- tests/components/tts/test_init.py | 442 ++++++++++++++++++------------ 1 file changed, 263 insertions(+), 179 deletions(-) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 8c21336b59..e7b3a818f6 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1,12 +1,11 @@ """The tests for the TTS component.""" from http import HTTPStatus -from unittest.mock import PropertyMock, patch +from typing import Any import pytest import voluptuous as vol from homeassistant.components import media_source, tts -from homeassistant.components.demo.tts import DemoProvider from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, @@ -18,10 +17,17 @@ from homeassistant.components.media_player import ( from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component from homeassistant.util.network import normalize_url -from tests.common import assert_setup_component, async_mock_service +from tests.common import ( + MockModule, + assert_setup_component, + async_mock_service, + mock_integration, + mock_platform, +) from tests.typing import ClientSessionGenerator ORIG_WRITE_TAGS = tts.SpeechManager.write_tags @@ -36,10 +42,68 @@ async def get_media_source_url(hass, media_content_id): return resolved.url +SUPPORT_LANGUAGES = ["de", "en", "en_US"] + +DEFAULT_LANG = "en" + + +class MockProvider(tts.Provider): + """Test speech API provider.""" + + def __init__(self, lang: str) -> None: + """Initialize test provider.""" + self._lang = lang + self.name = "Test" + + @property + def default_language(self) -> str: + """Return the default language.""" + return self._lang + + @property + def supported_languages(self) -> list[str]: + """Return list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotions.""" + return ["voice", "age"] + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + return ("mp3", b"") + + +class MockTTS: + """A mock TTS platform.""" + + PLATFORM_SCHEMA = tts.PLATFORM_SCHEMA.extend( + {vol.Optional(tts.CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} + ) + + def __init__(self, provider=None) -> None: + """Initialize.""" + if provider is None: + provider = MockProvider + self._provider = provider + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> tts.Provider: + """Set up a mock speech component.""" + return self._provider(config.get(tts.CONF_LANG, DEFAULT_LANG)) + + @pytest.fixture -def demo_provider(): - """Demo TTS provider.""" - return DemoProvider("en") +def test_provider(): + """Test TTS provider.""" + return MockProvider("en") @pytest.fixture(autouse=True) @@ -52,49 +116,52 @@ async def internal_url_mock(hass): @pytest.fixture -async def setup_tts(hass): +async def mock_tts(hass): """Mock TTS.""" - with patch("homeassistant.components.demo.async_setup", return_value=True): - assert await async_setup_component( - hass, tts.DOMAIN, {"tts": {"platform": "demo"}} - ) - await hass.async_block_till_done() + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS()) -async def test_setup_component_demo(hass: HomeAssistant, setup_tts) -> None: - """Set up the demo platform with defaults.""" - assert hass.services.has_service(tts.DOMAIN, "demo_say") +@pytest.fixture +async def setup_tts(hass, mock_tts): + """Mock TTS.""" + assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) + + +async def test_setup_component(hass: HomeAssistant, setup_tts) -> None: + """Set up a TTS platform with defaults.""" + assert hass.services.has_service(tts.DOMAIN, "test_say") assert hass.services.has_service(tts.DOMAIN, "clear_cache") - assert f"{tts.DOMAIN}.demo" in hass.config.components + assert f"{tts.DOMAIN}.test" in hass.config.components -async def test_setup_component_demo_no_access_cache_folder( - hass: HomeAssistant, mock_init_cache_dir +async def test_setup_component_no_access_cache_folder( + hass: HomeAssistant, mock_init_cache_dir, mock_tts ) -> None: - """Set up the demo platform with defaults.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform with defaults.""" + config = {tts.DOMAIN: {"platform": "test"}} mock_init_cache_dir.side_effect = OSError(2, "No access") assert not await async_setup_component(hass, tts.DOMAIN, config) - assert not hass.services.has_service(tts.DOMAIN, "demo_say") + assert not hass.services.has_service(tts.DOMAIN, "test_say") assert not hass.services.has_service(tts.DOMAIN, "clear_cache") async def test_setup_component_and_test_service( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -107,28 +174,28 @@ async def test_setup_component_and_test_service( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_config_language( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "language": "de"}} + config = {tts.DOMAIN: {"platform": "test", "language": "de"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -139,31 +206,28 @@ async def test_setup_component_and_test_service_with_config_language( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_config_language_special( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service with extend language.""" - import homeassistant.components.demo.tts as demo_tts - - demo_tts.SUPPORT_LANGUAGES.append("en_US") + """Set up a TTS platform and call service with extend language.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "language": "en_US"}} + config = {tts.DOMAIN: {"platform": "test", "language": "en_US"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -174,38 +238,38 @@ async def test_setup_component_and_test_service_with_config_language_special( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_wrong_conf_language( - hass: HomeAssistant, + hass: HomeAssistant, mock_tts ) -> None: - """Set up the demo platform and call service with wrong config.""" - config = {tts.DOMAIN: {"platform": "demo", "language": "ru"}} + """Set up a TTS platform and call service with wrong config.""" + config = {tts.DOMAIN: {"platform": "test", "language": "ru"}} with assert_setup_component(0, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) async def test_setup_component_and_test_service_with_service_language( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -217,21 +281,21 @@ async def test_setup_component_and_test_service_with_service_language( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ).is_file() async def test_setup_component_test_service_with_wrong_service_language( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) @@ -239,7 +303,7 @@ async def test_setup_component_test_service_with_wrong_service_language( with pytest.raises(HomeAssistantError): await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -249,24 +313,24 @@ async def test_setup_component_test_service_with_wrong_service_language( ) assert len(calls) == 0 assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_service_options( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service with options.""" + """Set up a TTS platform and call service with options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -281,32 +345,37 @@ async def test_setup_component_and_test_service_with_service_options( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_with_service_options_def( hass: HomeAssistant, empty_cache_dir ) -> None: - """Set up the demo platform and call service with default options.""" + """Set up a TTS platform and call service with default options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} - with assert_setup_component(1, tts.DOMAIN), patch( - "homeassistant.components.demo.tts.DemoProvider.default_options", - new_callable=PropertyMock(return_value={"voice": "alex"}), - ): + class MockProviderWithDefaults(MockProvider): + @property + def default_options(self): + return {"voice": "alex"} + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderWithDefaults)) + + with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -320,22 +389,22 @@ async def test_setup_component_and_test_with_service_options_def( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_service_options_wrong( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service with wrong options.""" + """Set up a TTS platform and call service with wrong options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) @@ -343,7 +412,7 @@ async def test_setup_component_and_test_service_with_service_options_wrong( with pytest.raises(HomeAssistantError): await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -358,24 +427,24 @@ async def test_setup_component_and_test_service_with_service_options_wrong( await hass.async_block_till_done() assert not ( empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_base_url_set( - hass: HomeAssistant, + hass: HomeAssistant, mock_tts ) -> None: - """Set up the demo platform with ``base_url`` set and call service.""" + """Set up a TTS platform with ``base_url`` set and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "base_url": "http://fnord"}} + config = {tts.DOMAIN: {"platform": "test", "base_url": "http://fnord"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -388,24 +457,24 @@ async def test_setup_component_and_test_service_with_base_url_set( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == "http://fnord" "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - "_en_-_demo.mp3" + "_en_-_test.mp3" ) async def test_setup_component_and_test_service_clear_cache( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service clear cache.""" + """Set up a TTS platform and call service clear cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -417,7 +486,7 @@ async def test_setup_component_and_test_service_clear_cache( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() await hass.services.async_call( @@ -425,17 +494,17 @@ async def test_setup_component_and_test_service_clear_cache( ) assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_receive_voice( - hass: HomeAssistant, demo_provider, hass_client: ClientSessionGenerator + hass: HomeAssistant, test_provider, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and call service and receive voice.""" + """Set up a TTS platform and call service and receive voice.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) @@ -444,7 +513,7 @@ async def test_setup_component_and_test_service_with_receive_voice( await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: message, @@ -456,39 +525,39 @@ async def test_setup_component_and_test_service_with_receive_voice( url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) client = await hass_client() req = await client.get(url) - _, demo_data = demo_provider.get_tts_audio("bla", "en") - demo_data = tts.SpeechManager.write_tags( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - demo_data, - demo_provider, + _, tts_data = test_provider.get_tts_audio("bla", "en") + tts_data = tts.SpeechManager.write_tags( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", + tts_data, + test_provider, message, "en", None, ) assert req.status == HTTPStatus.OK - assert await req.read() == demo_data + assert await req.read() == tts_data extension, data = await tts.async_get_media_source_audio( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) assert extension == "mp3" - assert demo_data == data + assert tts_data == data async def test_setup_component_and_test_service_with_receive_voice_german( - hass: HomeAssistant, demo_provider, hass_client: ClientSessionGenerator + hass: HomeAssistant, test_provider, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and call service and receive voice.""" + """Set up a TTS platform and call service and receive voice.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "language": "de"}} + config = {tts.DOMAIN: {"platform": "test", "language": "de"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -499,67 +568,67 @@ async def test_setup_component_and_test_service_with_receive_voice_german( url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) client = await hass_client() req = await client.get(url) - _, demo_data = demo_provider.get_tts_audio("bla", "de") - demo_data = tts.SpeechManager.write_tags( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3", - demo_data, - demo_provider, + _, tts_data = test_provider.get_tts_audio("bla", "de") + tts_data = tts.SpeechManager.write_tags( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3", + tts_data, + test_provider, "There is someone at the door.", "de", None, ) assert req.status == HTTPStatus.OK - assert await req.read() == demo_data + assert await req.read() == tts_data async def test_setup_component_and_web_view_wrong_file( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive wrong file from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive wrong file from web.""" + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() - url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.NOT_FOUND async def test_setup_component_and_web_view_wrong_filename( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive wrong filename from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive wrong filename from web.""" + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() - url = "/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en_-_demo.mp3" + url = "/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.NOT_FOUND async def test_setup_component_test_without_cache( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up demo platform without cache.""" + """Set up a TTS platform without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "cache": False}} + config = {tts.DOMAIN: {"platform": "test", "cache": False}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -569,24 +638,24 @@ async def test_setup_component_test_without_cache( assert len(calls) == 1 await hass.async_block_till_done() assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_test_with_cache_call_service_without_cache( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up demo platform with cache and call service without cache.""" + """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + config = {tts.DOMAIN: {"platform": "test", "cache": True}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -597,116 +666,131 @@ async def test_setup_component_test_with_cache_call_service_without_cache( assert len(calls) == 1 await hass.async_block_till_done() assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_test_with_cache_dir( - hass: HomeAssistant, empty_cache_dir, demo_provider + hass: HomeAssistant, empty_cache_dir, test_provider ) -> None: - """Set up demo platform with cache and call service without cache.""" + """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - _, demo_data = demo_provider.get_tts_audio("bla", "en") + _, tts_data = test_provider.get_tts_audio("bla", "en") cache_file = ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) with open(cache_file, "wb") as voice_file: - voice_file.write(demo_data) + voice_file.write(tts_data) - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + config = {tts.DOMAIN: {"platform": "test", "cache": True}} + + class MockProviderBoom(MockProvider): + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + # This should not be called, data should be fetched from cache + raise Exception("Boom!") + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderBoom)) with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) - with patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=(None, None), - ): - await hass.services.async_call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - blocking=True, - ) + await hass.services.async_call( + tts.DOMAIN, + "test_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) assert len(calls) == 1 assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) async def test_setup_component_test_with_error_on_get_tts(hass: HomeAssistant) -> None: - """Set up demo platform with wrong get_tts_audio.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform with wrong get_tts_audio.""" + config = {tts.DOMAIN: {"platform": "test"}} - with assert_setup_component(1, tts.DOMAIN), patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=(None, None), - ): + class MockProviderEmpty(MockProvider): + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + return (None, None) + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderEmpty)) + + with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) async def test_setup_component_load_cache_retrieve_without_mem_cache( hass: HomeAssistant, - demo_provider, + test_provider, empty_cache_dir, hass_client: ClientSessionGenerator, + mock_tts, ) -> None: """Set up component and load cache and get without mem cache.""" - _, demo_data = demo_provider.get_tts_audio("bla", "en") + _, tts_data = test_provider.get_tts_audio("bla", "en") cache_file = ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) with open(cache_file, "wb") as voice_file: - voice_file.write(demo_data) + voice_file.write(tts_data) - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + config = {tts.DOMAIN: {"platform": "test", "cache": True}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() - url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.OK - assert await req.read() == demo_data + assert await req.read() == tts_data async def test_setup_component_and_web_get_url( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive file from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive file from web.""" + config = {tts.DOMAIN: {"platform": "test"}} await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() url = "/api/tts_get_url" - data = {"platform": "demo", "message": "There is someone at the door."} + data = {"platform": "test", "message": "There is someone at the door."} req = await client.post(url, json=data) assert req.status == HTTPStatus.OK response = await req.json() assert response == { - "url": "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - "path": "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", + "url": "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", + "path": "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", } async def test_setup_component_and_web_get_url_bad_config( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive wrong file from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive wrong file from web.""" + config = {tts.DOMAIN: {"platform": "test"}} await async_setup_component(hass, tts.DOMAIN, config) @@ -719,25 +803,25 @@ async def test_setup_component_and_web_get_url_bad_config( assert req.status == HTTPStatus.BAD_REQUEST -async def test_tags_with_wave(hass: HomeAssistant, demo_provider) -> None: - """Set up the demo platform and call service and receive voice.""" +async def test_tags_with_wave(hass: HomeAssistant, test_provider) -> None: + """Set up a TTS platform and call service and receive voice.""" # below data represents an empty wav file - demo_data = bytes.fromhex( + tts_data = bytes.fromhex( "52 49 46 46 24 00 00 00 57 41 56 45 66 6d 74 20 10 00 00 00 01 00 02 00" + "22 56 00 00 88 58 01 00 04 00 10 00 64 61 74 61 00 00 00 00" ) tagged_data = ORIG_WRITE_TAGS( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.wav", - demo_data, - demo_provider, + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.wav", + tts_data, + test_provider, "AI person is in front of your door.", "en", None, ) - assert tagged_data != demo_data + assert tagged_data != tts_data @pytest.mark.parametrize( @@ -781,10 +865,10 @@ def test_invalid_base_url(value) -> None: @pytest.mark.parametrize( ("engine", "language", "options", "cache", "result_engine", "result_query"), ( - (None, None, None, None, "demo", ""), - (None, "de", None, None, "demo", "language=de"), - (None, "de", {"voice": "henk"}, None, "demo", "language=de&voice=henk"), - (None, "de", None, True, "demo", "cache=true&language=de"), + (None, None, None, None, "test", ""), + (None, "de", None, None, "test", "language=de"), + (None, "de", {"voice": "henk"}, None, "test", "language=de&voice=henk"), + (None, "de", None, True, "test", "cache=true&language=de"), ), ) async def test_generate_media_source_id( From a32c78238eccee99ac9558b08be2c8e3c1a145ca Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Mon, 27 Mar 2023 23:06:30 +1100 Subject: [PATCH 0187/1165] Bump lifx dependencies (#90345) --- homeassistant/components/lifx/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 0019f68ab9..65f4e7ecef 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -42,7 +42,7 @@ "quality_scale": "platinum", "requirements": [ "aiolifx==0.8.9", - "aiolifx_effects==0.3.1", - "aiolifx_themes==0.4.0" + "aiolifx_effects==0.3.2", + "aiolifx_themes==0.4.5" ] } diff --git a/requirements_all.txt b/requirements_all.txt index cf3fbc10f4..91238f06f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -196,10 +196,10 @@ aiokef==0.2.16 aiolifx==0.8.9 # homeassistant.components.lifx -aiolifx_effects==0.3.1 +aiolifx_effects==0.3.2 # homeassistant.components.lifx -aiolifx_themes==0.4.0 +aiolifx_themes==0.4.5 # homeassistant.components.livisi aiolivisi==0.0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1a499c45d..f16cbf7c37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -180,10 +180,10 @@ aiokafka==0.7.2 aiolifx==0.8.9 # homeassistant.components.lifx -aiolifx_effects==0.3.1 +aiolifx_effects==0.3.2 # homeassistant.components.lifx -aiolifx_themes==0.4.0 +aiolifx_themes==0.4.5 # homeassistant.components.livisi aiolivisi==0.0.19 From 2ce3c014ff8a1663a74554d4f9e553ea69041dbf Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Mon, 27 Mar 2023 10:27:55 -0300 Subject: [PATCH 0188/1165] Move Proxmox VE constants (#90357) * Move constants to const.py * Update homeassistant/components/proxmoxve/const.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/const.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/proxmoxve/__init__.py | 38 +++++++++---------- homeassistant/components/proxmoxve/const.py | 22 +++++++++++ 2 files changed, 40 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/proxmoxve/const.py diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 8e8842abf4..f8e350f2b1 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import timedelta -import logging from proxmoxer import ProxmoxAPI from proxmoxer.backends.https import AuthenticationError @@ -28,26 +27,25 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) +from .const import ( + _LOGGER, + CONF_CONTAINERS, + CONF_NODE, + CONF_NODES, + CONF_REALM, + CONF_VMS, + COORDINATORS, + DEFAULT_PORT, + DEFAULT_REALM, + DEFAULT_VERIFY_SSL, + DOMAIN, + PROXMOX_CLIENTS, + TYPE_CONTAINER, + TYPE_VM, + UPDATE_INTERVAL, +) + PLATFORMS = [Platform.BINARY_SENSOR] -DOMAIN = "proxmoxve" -PROXMOX_CLIENTS = "proxmox_clients" -CONF_REALM = "realm" -CONF_NODE = "node" -CONF_NODES = "nodes" -CONF_VMS = "vms" -CONF_CONTAINERS = "containers" - -COORDINATORS = "coordinators" -API_DATA = "api_data" - -DEFAULT_PORT = 8006 -DEFAULT_REALM = "pam" -DEFAULT_VERIFY_SSL = True -TYPE_VM = 0 -TYPE_CONTAINER = 1 -UPDATE_INTERVAL = 60 - -_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py new file mode 100644 index 0000000000..6477c08146 --- /dev/null +++ b/homeassistant/components/proxmoxve/const.py @@ -0,0 +1,22 @@ +"""Constants for ProxmoxVE.""" + +import logging + +DOMAIN = "proxmoxve" +PROXMOX_CLIENTS = "proxmox_clients" +CONF_REALM = "realm" +CONF_NODE = "node" +CONF_NODES = "nodes" +CONF_VMS = "vms" +CONF_CONTAINERS = "containers" + +COORDINATORS = "coordinators" + +DEFAULT_PORT = 8006 +DEFAULT_REALM = "pam" +DEFAULT_VERIFY_SSL = True +TYPE_VM = 0 +TYPE_CONTAINER = 1 +UPDATE_INTERVAL = 60 + +_LOGGER = logging.getLogger(__package__) From fd3280260d79ba5a774292624321639620531869 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Mar 2023 16:39:51 +0200 Subject: [PATCH 0189/1165] Remove unreachable continue statement in imap push coordinator (#90361) --- homeassistant/components/imap/coordinator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index e9bbb62301..69f291df6e 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -199,7 +199,6 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): self.async_set_update_error(UpdateFailed("Lost connection")) await self._cleanup() await asyncio.sleep(BACKOFF_TIME) - continue async def shutdown(self, *_) -> None: """Close resources.""" From 89f89cab2ca8778de717bb2564fcb46830ce7101 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 16:55:10 +0200 Subject: [PATCH 0190/1165] Use entity name translations in Verisure (#90362) --- .../components/verisure/binary_sensor.py | 2 +- homeassistant/components/verisure/sensor.py | 4 ++-- homeassistant/components/verisure/strings.json | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index a960107c71..68d549eaa5 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -98,7 +98,7 @@ class VerisureEthernetStatus( _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_has_entity_name = True - _attr_name = "Ethernet status" + _attr_translation_key = "ethernet" @property def unique_id(self) -> str: diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 0b519b4726..7c9639b654 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -47,7 +47,7 @@ class VerisureThermometer( _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_has_entity_name = True - _attr_name = "Temperature" + _attr_translation_key = "temperature" _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS _attr_state_class = SensorStateClass.MEASUREMENT @@ -99,7 +99,7 @@ class VerisureHygrometer( _attr_device_class = SensorDeviceClass.HUMIDITY _attr_has_entity_name = True - _attr_name = "Humidity" + _attr_translation_key = "humidity" _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index c8326d7375..17feb4a7fe 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -56,5 +56,20 @@ "error": { "code_format_mismatch": "The default PIN code does not match the required number of digits" } + }, + "entity": { + "binary_sensor": { + "ethernet": { + "name": "Ethernet status" + } + }, + "sensor": { + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + } + } } } From f4fda55405764446c571701a23860ce0fe0b1254 Mon Sep 17 00:00:00 2001 From: javicalle <31999997+javicalle@users.noreply.github.com> Date: Mon, 27 Mar 2023 17:57:40 +0200 Subject: [PATCH 0191/1165] Fix `quirk_class_validator` in ZHA unit tests (#90140) * Fix `quirk_class_validator` Fix the `quirk_class_validator` for quirks with more than 1 module level * fix black * Shorten `quirk_cls` in `clss` Co-authored-by: TheJulianJES * Update comment --------- Co-authored-by: TheJulianJES --- tests/components/zha/test_registries.py | 26 +++++++------------------ 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 6a6bf758ce..80b1f10f56 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -1,4 +1,5 @@ """Test ZHA registries.""" +import importlib import inspect from unittest import mock @@ -440,24 +441,11 @@ def test_quirk_classes() -> None: def find_quirk_class(base_obj, quirk_mod, quirk_cls): """Find a specific quirk class.""" - mods = dict(inspect.getmembers(base_obj, inspect.ismodule)) - # Check if we have found the right module - if quirk_mod in mods: - # If so, look for the class - clss = dict(inspect.getmembers(mods[quirk_mod], inspect.isclass)) - if quirk_cls in clss: - # Quirk class found - return True - - else: - # Recurse into other modules - for mod in mods: - if not mods[mod].__name__.startswith("zhaquirks."): - continue - if find_quirk_class(mods[mod], quirk_mod, quirk_cls): - return True - return False + module = importlib.import_module(quirk_mod) + clss = dict(inspect.getmembers(module, inspect.isclass)) + # Check quirk_cls in module classes + return quirk_cls in clss def quirk_class_validator(value): """Validate quirk classes during self test.""" @@ -471,9 +459,9 @@ def test_quirk_classes() -> None: quirk_class_validator(v) return - quirk_tok = value.split(".") + quirk_tok = value.rsplit(".", 1) if len(quirk_tok) != 2: - # quirk_class is always __module__.__class__ + # quirk_class is at least __module__.__class__ raise ValueError(f"Invalid quirk class : '{value}'") if not find_quirk_class(zhaquirks, quirk_tok[0], quirk_tok[1]): From b033232b06e14759bd886204e2efd3347a8384be Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 19:49:40 +0200 Subject: [PATCH 0192/1165] Filter out ASCII tab or newline from input URLs (#90348) --- .../components/http/security_filter.py | 18 +++++++ tests/components/http/test_security_filter.py | 51 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index a9b32bd7f4..e8e3aa4699 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -35,6 +35,9 @@ FILTERS: Final = re.compile( ) # fmt: on +# Unsafe bytes to be removed per WHATWG spec +UNSAFE_URL_BYTES = ["\t", "\r", "\n"] + @callback def setup_security_filter(app: Application) -> None: @@ -51,6 +54,21 @@ def setup_security_filter(app: Application) -> None: request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process request and block commonly known exploit attempts.""" + for unsafe_byte in UNSAFE_URL_BYTES: + if unsafe_byte in request.path: + _LOGGER.warning( + "Filtered a request with an unsafe byte in path: %s", + request.raw_path, + ) + raise HTTPBadRequest + + if unsafe_byte in request.query_string: + _LOGGER.warning( + "Filtered a request with unsafe byte query string: %s", + request.raw_path, + ) + raise HTTPBadRequest + if FILTERS.search(_recursive_unquote(request.path)): _LOGGER.warning( "Filtered a potential harmful request to: %s", request.raw_path diff --git a/tests/components/http/test_security_filter.py b/tests/components/http/test_security_filter.py index 1c139a5916..5469b7ebfa 100644 --- a/tests/components/http/test_security_filter.py +++ b/tests/components/http/test_security_filter.py @@ -107,3 +107,54 @@ async def test_bad_requests( if fail_on_query_string: message = "Filtered a request with a potential harmful query string:" assert message in caplog.text + + +@pytest.mark.parametrize( + ("request_path", "request_params", "fail_on_query_string"), + [ + ("/some\thing", {}, False), + ("/new\nline/cinema", {}, False), + ("/return\r/to/sender", {}, False), + ("/", {"some": "\thing"}, True), + ("/", {"\newline": "cinema"}, True), + ("/", {"return": "t\rue"}, True), + ], +) +async def test_bad_requests_with_unsafe_bytes( + request_path, + request_params, + fail_on_query_string, + aiohttp_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + loop, +) -> None: + """Test request with unsafe bytes in their URLs.""" + app = web.Application() + app.router.add_get("/{all:.*}", mock_handler) + + setup_security_filter(app) + + mock_api_client = await aiohttp_client(app) + + # Manual params handling + if request_params: + raw_params = "&".join(f"{val}={key}" for val, key in request_params.items()) + man_params = f"?{raw_params}" + else: + man_params = "" + + http = urllib3.PoolManager() + resp = await loop.run_in_executor( + None, + http.request, + "GET", + f"http://{mock_api_client.host}:{mock_api_client.port}{request_path}{man_params}", + request_params, + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + + message = "Filtered a request with an unsafe byte in path:" + if fail_on_query_string: + message = "Filtered a request with unsafe byte query string:" + assert message in caplog.text From d59e2b13496e51f30ed05f3c201423fc09e88a95 Mon Sep 17 00:00:00 2001 From: Trevor Bernard Date: Mon, 27 Mar 2023 12:57:56 -0500 Subject: [PATCH 0193/1165] Add "stream" to default_config (#90153) * add stream to manifest * Update __init__.py remove av check from init * Update homeassistant/components/default_config/__init__.py * Update requirements --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- homeassistant/components/default_config/__init__.py | 11 +---------- homeassistant/components/default_config/manifest.json | 1 + homeassistant/package_constraints.txt | 2 ++ 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index 574d97c6d2..d91d06949e 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -1,10 +1,4 @@ """Component providing default configuration for new users.""" - -try: - import av -except ImportError: - av = None - from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType @@ -18,7 +12,4 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not is_hassio(hass): await async_setup_component(hass, "backup", config) - if av is None: - return True - - return await async_setup_component(hass, "stream", config) + return True diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index d4faaddaa5..a1add47594 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -33,6 +33,7 @@ "schedule", "script", "ssdp", + "stream", "sun", "system_health", "tag", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd568947cf..ae190115b2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,5 +1,6 @@ PyJWT==2.6.0 PyNaCl==1.5.0 +PyTurboJPEG==1.6.7 aiodiscover==1.4.14 aiohttp==3.8.4 aiohttp_cors==0.7.0 @@ -20,6 +21,7 @@ ciso8601==2.3.0 cryptography==40.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 +ha-av==10.0.0 hass-nabucasa==0.62.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 From a4051121421bab0ec3784e7019882674a5b0f879 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 19:59:57 +0200 Subject: [PATCH 0194/1165] Add state translations for Script entities (#90354) --- homeassistant/components/script/strings.json | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index c78e4265cb..b9624f16a3 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -6,6 +6,29 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "current": { + "name": "[%key:component::automation::entity_component::_::state_attributes::current::name%]" + }, + "last_action": { + "name": "Last action" + }, + "last_triggered": { + "name": "[%key:component::automation::entity_component::_::state_attributes::last_triggered::name%]" + }, + "max": { + "name": "Max running scripts" + }, + "mode": { + "name": "[%key:component::automation::entity_component::_::state_attributes::mode::name%]", + "state": { + "parallel": "[%key:component::automation::entity_component::_::state_attributes::mode::state::parallel%]", + "queued": "[%key:component::automation::entity_component::_::state_attributes::mode::state::queued%]", + "restart": "[%key:component::automation::entity_component::_::state_attributes::mode::state::restart%]", + "single": "[%key:component::automation::entity_component::_::state_attributes::mode::state::single%]" + } + } } } } From f84651b14e1d51999e5919f745a553a94a87eada Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Mar 2023 20:00:54 +0200 Subject: [PATCH 0195/1165] Improve tts test coverage (#90370) --- tests/components/tts/test_init.py | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index e7b3a818f6..251ed9b30c 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) +from homeassistant.components.media_source import Unresolvable from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -398,6 +399,54 @@ async def test_setup_component_and_test_with_service_options_def( ).is_file() +async def test_setup_component_and_test_with_service_options_def_2( + hass: HomeAssistant, empty_cache_dir +) -> None: + """Set up a TTS platform and call service with default options. + + This tests merging default and user provided options. + """ + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = {tts.DOMAIN: {"platform": "test"}} + + class MockProviderWithDefaults(MockProvider): + @property + def default_options(self): + return {"voice": "alex"} + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderWithDefaults)) + + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "test_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de", + tts.ATTR_OPTIONS: {"age": 5}, + }, + blocking=True, + ) + opt_hash = tts._hash_options({"voice": "alex", "age": 5}) + + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC + assert ( + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" + ) + await hass.async_block_till_done() + assert ( + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" + ).is_file() + + async def test_setup_component_and_test_service_with_service_options_wrong( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: @@ -718,6 +767,8 @@ async def test_setup_component_test_with_cache_dir( async def test_setup_component_test_with_error_on_get_tts(hass: HomeAssistant) -> None: """Set up a TTS platform with wrong get_tts_audio.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + config = {tts.DOMAIN: {"platform": "test"}} class MockProviderEmpty(MockProvider): @@ -733,6 +784,19 @@ async def test_setup_component_test_with_error_on_get_tts(hass: HomeAssistant) - with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) + await hass.services.async_call( + tts.DOMAIN, + "test_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 + with pytest.raises(Unresolvable): + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + async def test_setup_component_load_cache_retrieve_without_mem_cache( hass: HomeAssistant, From 18933df95c0619db7500edab1c2162b80f70638e Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 27 Mar 2023 20:06:59 +0200 Subject: [PATCH 0196/1165] Clean dead code from matter (#90369) --- homeassistant/components/matter/models.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 2575b16e8b..eaa9ccf9a0 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -2,8 +2,8 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import asdict, dataclass -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass +from typing import Any from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -13,19 +13,6 @@ from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform from homeassistant.helpers.entity import EntityDescription -if TYPE_CHECKING: - from _typeshed import DataclassInstance - - -class DataclassMustHaveAtLeastOne: - """A dataclass that must have at least one input parameter that is not None.""" - - def __post_init__(self: DataclassInstance) -> None: - """Post dataclass initialization.""" - if all(val is None for val in asdict(self).values()): - raise ValueError("At least one input parameter must not be None") - - SensorValueTypes = type[ clusters.uint | int | clusters.Nullable | clusters.float32 | float ] From 1937d803c5f9fba91b4f9c810fa376d1dd8a13ff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 20:08:20 +0200 Subject: [PATCH 0197/1165] Add RestoreEntity pylint checks to all platforms (#90020) --- pylint/plugins/hass_enforce_type_hints.py | 84 +++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 4fb471e214..a84d578cf5 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -710,6 +710,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="AlarmControlPanelEntity", matches=[ @@ -793,6 +797,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="BinarySensorEntity", matches=[ @@ -836,6 +844,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="CalendarEntity", matches=[ @@ -860,6 +872,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="Camera", matches=[ @@ -974,6 +990,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ClimateEntity", matches=[ @@ -1147,6 +1167,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="CoverEntity", matches=[ @@ -1246,6 +1270,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="BaseTrackerEntity", matches=[ @@ -1319,6 +1347,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -1400,6 +1432,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="GeolocationEvent", matches=[ @@ -1427,6 +1463,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ImageProcessingEntity", matches=[ @@ -1470,6 +1510,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -1525,6 +1569,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -1624,6 +1672,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="LockEntity", matches=[ @@ -1714,6 +1766,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="MediaPlayerEntity", matches=[ @@ -2118,6 +2174,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2185,6 +2245,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="SelectEntity", matches=[ @@ -2275,6 +2339,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2334,6 +2402,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2445,6 +2517,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2580,6 +2656,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="WaterHeaterEntity", matches=[ @@ -2661,6 +2741,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="WeatherEntity", matches=[ From 506a916a136249898e95f5e6b989d9febf9c4c06 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Mar 2023 20:37:31 +0200 Subject: [PATCH 0198/1165] Add reauth flow to dormakaba dkey (#90225) --- .../components/dormakaba_dkey/__init__.py | 6 +- .../components/dormakaba_dkey/config_flow.py | 50 +++++++++++++-- .../components/dormakaba_dkey/strings.json | 5 ++ .../dormakaba_dkey/test_config_flow.py | 63 +++++++++++++++++++ 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 2f57d9802b..4903e46b8d 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging from py_dormakaba_dkey import DKEYLock -from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS +from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS, NotAssociated from py_dormakaba_dkey.models import AssociationData from homeassistant.components import bluetooth @@ -13,7 +13,7 @@ from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackM from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS @@ -60,6 +60,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await lock.update() await lock.disconnect() + except NotAssociated as ex: + raise ConfigEntryAuthFailed("Not associated") from ex except DKEY_EXCEPTIONS as ex: raise UpdateFailed(str(ex)) from ex diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 3da1fd841f..f03861d015 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Dormakaba dKey integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -12,6 +13,7 @@ from homeassistant import config_entries from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, + async_last_service_info, ) from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import FlowResult @@ -32,12 +34,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + def __init__(self) -> None: """Initialize the config flow.""" self._lock: DKEYLock | None = None # Populated by user step self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} - # Populated by bluetooth and user steps + # Populated by bluetooth, reauth_confirm and user steps self._discovery_info: BluetoothServiceInfoBleak | None = None async def async_step_user( @@ -113,6 +117,36 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_associate() + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauthorization request.""" + self._reauth_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 + ) -> FlowResult: + """Handle reauthorization flow.""" + errors = {} + reauth_entry = self._reauth_entry + assert reauth_entry is not None + + if user_input is not None: + if ( + discovery_info := async_last_service_info( + self.hass, reauth_entry.data[CONF_ADDRESS], True + ) + ) is None: + errors = {"base": "no_longer_in_range"} + else: + self._discovery_info = discovery_info + return await self.async_step_associate() + + return self.async_show_form( + step_id="reauth_confirm", data_schema=vol.Schema({}), errors=errors + ) + async def async_step_associate( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -143,14 +177,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: + data = { + CONF_ADDRESS: self._discovery_info.device.address, + CONF_ASSOCIATION_DATA: association_data.to_json(), + } + if reauth_entry := self._reauth_entry: + self.hass.config_entries.async_update_entry(reauth_entry, data=data) + await self.hass.config_entries.async_reload(reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=lock.device_info.device_name or lock.device_info.device_id or lock.name, - data={ - CONF_ADDRESS: self._discovery_info.device.address, - CONF_ASSOCIATION_DATA: association_data.to_json(), - }, + data=data, ) return self.async_show_form( diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index d07deaca82..efe9d3acb5 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -11,6 +11,9 @@ "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, + "reauth_confirm": { + "description": "The activation code is no longer valid, a new unused activation code is needed.\n\n" + }, "associate": { "description": "Provide an unused activation code.\n\nTo create an activation code, create a new key in the dKey admin app, then choose to share the key and share an activation code.\n\nMake sure to close the dKey admin app before proceeding.", "data": { @@ -19,6 +22,7 @@ } }, "error": { + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "invalid_code": "Invalid activation code. An activation code consist of 8 characters, separated by a dash, e.g. GBZT-HXC0.", "wrong_code": "Wrong activation code. Note that an activation code can only be used once." }, @@ -26,6 +30,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } } diff --git a/tests/components/dormakaba_dkey/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index 70c86524be..8c0156e221 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -296,3 +296,66 @@ async def test_bluetooth_step_cannot_associate(hass: HomeAssistant, exc, error) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "associate" assert result["errors"] == {"base": error} + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauthentication.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DKEY_DISCOVERY_INFO.address, + data={"address": DKEY_DISCOVERY_INFO.address}, + ) + 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"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_last_service_info", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "no_longer_in_range"} + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_last_service_info", + return_value=DKEY_DISCOVERY_INFO, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate", + return_value=AssociationData(b"1234", b"AABBCCDD"), + ) as mock_associate, patch( + "homeassistant.components.dormakaba_dkey.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"activation_code": "1234-1234"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == { + CONF_ADDRESS: DKEY_DISCOVERY_INFO.address, + "association_data": {"key_holder_id": "31323334", "secret": "4141424243434444"}, + } + mock_associate.assert_awaited_once_with("1234-1234") From 2ceb24e5d0569ad5ca0a9629da2f913221980623 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 20:49:49 +0200 Subject: [PATCH 0199/1165] Fail CI if codecov upload fails (#90363) --- .github/workflows/ci.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f4e04059d1..e4fd319e71 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1100,7 +1100,10 @@ jobs: if: needs.info.outputs.test_full_suite == 'true' uses: codecov/codecov-action@v3.1.1 with: + fail_ci_if_error: true flags: full-suite - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' uses: codecov/codecov-action@v3.1.1 + with: + fail_ci_if_error: true From 96698813efe605056bc2e6005b2529a51901660d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 27 Mar 2023 21:19:09 +0200 Subject: [PATCH 0200/1165] Cleanup command_line (#90268) * Cleanup command_line * Fix ipv6 resolver * Fix fix * Fix tests * Align states --- .../components/command_line/__init__.py | 61 ------------------ .../components/command_line/binary_sensor.py | 28 ++------- .../components/command_line/cover.py | 33 +++------- .../components/command_line/sensor.py | 38 +++--------- .../components/command_line/switch.py | 49 +++++++++------ .../components/command_line/utils.py | 62 +++++++++++++++++++ tests/components/command_line/test_cover.py | 6 +- tests/components/command_line/test_sensor.py | 2 +- tests/components/command_line/test_switch.py | 4 +- 9 files changed, 122 insertions(+), 161 deletions(-) create mode 100644 homeassistant/components/command_line/utils.py diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index c0713d0780..fe0640d3ef 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -1,62 +1 @@ """The command_line component.""" -from __future__ import annotations - -import logging -import subprocess - -_LOGGER = logging.getLogger(__name__) - - -def call_shell_with_timeout( - command: str, timeout: int, *, log_return_code: bool = True -) -> int: - """Run a shell command with a timeout. - - If log_return_code is set to False, it will not print an error if a non-zero - return code is returned. - """ - try: - _LOGGER.debug("Running command: %s", command) - subprocess.check_output( - command, - shell=True, # nosec # shell by design - timeout=timeout, - close_fds=False, # required for posix_spawn - ) - return 0 - except subprocess.CalledProcessError as proc_exception: - if log_return_code: - _LOGGER.error( - "Command failed (with return code %s): %s", - proc_exception.returncode, - command, - ) - return proc_exception.returncode - except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", command) - return -1 - except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec command: %s", command) - return -1 - - -def check_output_or_log(command: str, timeout: int) -> str | None: - """Run a shell command with a timeout and return the output.""" - try: - return_value = subprocess.check_output( - command, - shell=True, # nosec # shell by design - timeout=timeout, - close_fds=False, # required for posix_spawn - ) - return return_value.strip().decode("utf-8") - except subprocess.CalledProcessError as err: - _LOGGER.error( - "Command failed (with return code %s): %s", err.returncode, command - ) - except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", command) - except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec command: %s", command) - - return None diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 2e1ddb7a96..0c2edb8f19 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -25,10 +25,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS @@ -65,10 +61,6 @@ async def async_setup_platform( await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - binary_sensor_config = vol.Schema( - TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(config) - name: str = config.get(CONF_NAME, DEFAULT_NAME) command: str = config[CONF_COMMAND] payload_off: str = config[CONF_PAYLOAD_OFF] @@ -84,8 +76,6 @@ async def async_setup_platform( async_add_entities( [ CommandBinarySensor( - hass, - binary_sensor_config, data, name, device_class, @@ -99,13 +89,11 @@ async def async_setup_platform( ) -class CommandBinarySensor(TemplateEntity, BinarySensorEntity): +class CommandBinarySensor(BinarySensorEntity): """Representation of a command line binary sensor.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, data: CommandSensorData, name: str, device_class: BinarySensorDeviceClass | None, @@ -115,19 +103,14 @@ class CommandBinarySensor(TemplateEntity, BinarySensorEntity): unique_id: str | None, ) -> None: """Initialize the Command line binary sensor.""" - TemplateEntity.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) self.data = data + self._attr_name = name self._attr_device_class = device_class self._attr_is_on = None self._payload_on = payload_on self._payload_off = payload_off self._value_template = value_template + self._attr_unique_id = unique_id async def async_update(self) -> None: """Get the latest data and updates the state.""" @@ -135,9 +118,10 @@ class CommandBinarySensor(TemplateEntity, BinarySensorEntity): value = self.data.value if self._value_template is not None: - value = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, value, False + value = self._value_template.async_render_with_possible_json_value( + value, None ) + self._attr_is_on = None if value == self._payload_on: self._attr_is_on = True elif value == self._payload_off: diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 53773ae4e9..e477affc85 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -22,14 +22,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import call_shell_with_timeout, check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import call_shell_with_timeout, check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -69,14 +65,8 @@ async def async_setup_platform( if value_template is not None: value_template.hass = hass - cover_config = vol.Schema( - TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(device_config) - covers.append( CommandCover( - hass, - cover_config, device_config.get(CONF_FRIENDLY_NAME, device_name), device_config[CONF_COMMAND_OPEN], device_config[CONF_COMMAND_CLOSE], @@ -95,13 +85,11 @@ async def async_setup_platform( async_add_entities(covers) -class CommandCover(TemplateEntity, CoverEntity): +class CommandCover(CoverEntity): """Representation a command line cover.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, name: str, command_open: str, command_close: str, @@ -112,13 +100,7 @@ class CommandCover(TemplateEntity, CoverEntity): unique_id: str | None, ) -> None: """Initialize the cover.""" - TemplateEntity.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) + self._attr_name = name self._state: int | None = None self._command_open = command_open self._command_close = command_close @@ -126,6 +108,7 @@ class CommandCover(TemplateEntity, CoverEntity): self._command_state = command_state self._value_template = value_template self._timeout = timeout + self._attr_unique_id = unique_id self._attr_should_poll = bool(command_state) def _move_cover(self, command: str) -> bool: @@ -170,10 +153,12 @@ class CommandCover(TemplateEntity, CoverEntity): if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._value_template: - payload = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, payload + payload = self._value_template.async_render_with_possible_json_value( + payload, None ) - self._state = int(payload) + self._state = None + if payload: + self._state = int(payload) def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 24224c12ca..f459e41566 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -22,7 +22,6 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError @@ -30,14 +29,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_SENSOR_BASE_SCHEMA, - TemplateSensor, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -72,10 +67,6 @@ async def async_setup_platform( await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - sensor_config = vol.Schema( - TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(config) - name: str = config[CONF_NAME] command: str = config[CONF_COMMAND] unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) @@ -90,8 +81,6 @@ async def async_setup_platform( async_add_entities( [ CommandSensor( - hass, - sensor_config, data, name, unit, @@ -104,13 +93,11 @@ async def async_setup_platform( ) -class CommandSensor(TemplateSensor, SensorEntity): +class CommandSensor(SensorEntity): """Representation of a sensor that is using shell commands.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, data: CommandSensorData, name: str, unit_of_measurement: str | None, @@ -119,18 +106,14 @@ class CommandSensor(TemplateSensor, SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" - TemplateSensor.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) + self._attr_name = name self.data = data self._attr_extra_state_attributes = {} self._json_attributes = json_attributes self._attr_native_value = None self._value_template = value_template + self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_unique_id = unique_id async def async_update(self) -> None: """Get the latest data and updates the state.""" @@ -155,13 +138,12 @@ class CommandSensor(TemplateSensor, SensorEntity): else: _LOGGER.warning("Empty reply found when expecting JSON data") - if value is None: - value = STATE_UNKNOWN elif self._value_template is not None: - self._attr_native_value = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, - value, - STATE_UNKNOWN, + self._attr_native_value = ( + self._value_template.async_render_with_possible_json_value( + value, + None, + ) ) else: self._attr_native_value = value diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 7142f14e82..3c344891fb 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -24,12 +24,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import call_shell_with_timeout, check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import call_shell_with_timeout, check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -51,15 +51,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Find and return switches controlled by shell commands.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) devices: dict[str, Any] = config.get(CONF_SWITCHES, {}) switches = [] @@ -92,7 +92,7 @@ def setup_platform( _LOGGER.error("No switches added") return - add_entities(switches) + async_add_entities(switches) class CommandSwitch(SwitchEntity): @@ -123,11 +123,16 @@ class CommandSwitch(SwitchEntity): self._attr_unique_id = unique_id self._attr_should_poll = bool(command_state) - def _switch(self, command: str) -> bool: + async def _switch(self, command: str) -> bool: """Execute the actual commands.""" _LOGGER.info("Running command: %s", command) - success = call_shell_with_timeout(command, self._timeout) == 0 + success = ( + await self.hass.async_add_executor_job( + call_shell_with_timeout, command, self._timeout + ) + == 0 + ) if not success: _LOGGER.error("Command failed: %s", command) @@ -160,26 +165,30 @@ class CommandSwitch(SwitchEntity): if TYPE_CHECKING: return None - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" if self._command_state: - payload = str(self._query_state()) + payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._icon_template: - self._attr_icon = self._icon_template.render_with_possible_json_value( - payload + self._attr_icon = ( + self._icon_template.async_render_with_possible_json_value(payload) ) if self._value_template: - payload = self._value_template.render_with_possible_json_value(payload) - self._attr_is_on = payload.lower() == "true" + payload = self._value_template.async_render_with_possible_json_value( + payload, None + ) + self._attr_is_on = None + if payload: + self._attr_is_on = payload.lower() == "true" - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if self._switch(self._command_on) and not self._command_state: + if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if self._switch(self._command_off) and not self._command_state: + if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py new file mode 100644 index 0000000000..2d42732190 --- /dev/null +++ b/homeassistant/components/command_line/utils.py @@ -0,0 +1,62 @@ +"""The command_line component utils.""" +from __future__ import annotations + +import logging +import subprocess + +_LOGGER = logging.getLogger(__name__) + + +def call_shell_with_timeout( + command: str, timeout: int, *, log_return_code: bool = True +) -> int: + """Run a shell command with a timeout. + + If log_return_code is set to False, it will not print an error if a non-zero + return code is returned. + """ + try: + _LOGGER.debug("Running command: %s", command) + subprocess.check_output( + command, + shell=True, # nosec # shell by design + timeout=timeout, + close_fds=False, # required for posix_spawn + ) + return 0 + except subprocess.CalledProcessError as proc_exception: + if log_return_code: + _LOGGER.error( + "Command failed (with return code %s): %s", + proc_exception.returncode, + command, + ) + return proc_exception.returncode + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + return -1 + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + return -1 + + +def check_output_or_log(command: str, timeout: int) -> str | None: + """Run a shell command with a timeout and return the output.""" + try: + return_value = subprocess.check_output( + command, + shell=True, # nosec # shell by design + timeout=timeout, + close_fds=False, # required for posix_spawn + ) + return return_value.strip().decode("utf-8") + except subprocess.CalledProcessError as err: + _LOGGER.error( + "Command failed (with return code %s): %s", err.returncode, command + ) + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + + return None diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index bfb74832f9..a650bd6c4f 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -42,7 +42,7 @@ async def test_no_covers(caplog: pytest.LogCaptureFixture, hass: HomeAssistant) """Test that the cover does not polls when there's no state command.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ): await setup_test_entity(hass, {}) @@ -53,7 +53,7 @@ async def test_no_poll_when_cover_has_no_command_state(hass: HomeAssistant) -> N """Test that the cover does not polls when there's no state command.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ) as check_output: await setup_test_entity(hass, {"test": {}}) @@ -66,7 +66,7 @@ async def test_poll_when_cover_has_command_state(hass: HomeAssistant) -> None: """Test that the cover polls when there's a state command.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ) as check_output: await setup_test_entity(hass, {"test": {"command_state": "echo state"}}) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 5aab14225f..4643891691 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -88,7 +88,7 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: """Ensure command with templates and quotes get rendered properly.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"Works\n", ) as check_output: await setup_test_entities( diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index ac1ae35712..bc8eadcb22 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -323,7 +323,7 @@ async def test_switch_command_state_code_exceptions( """Test that switch state code exceptions are handled correctly.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", side_effect=[ subprocess.TimeoutExpired("cmd", 10), subprocess.SubprocessError(), @@ -356,7 +356,7 @@ async def test_switch_command_state_value_exceptions( """Test that switch state value exceptions are handled correctly.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", side_effect=[ subprocess.TimeoutExpired("cmd", 10), subprocess.SubprocessError(), From 71b5ccee233840248b7a78d8c8cb8092d53ad6fd Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 27 Mar 2023 22:22:36 +0300 Subject: [PATCH 0201/1165] Fix generic_hygrostat error at startup (#88764) --- .../components/generic_hygrostat/humidifier.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index dfd6be14e6..73d876b354 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -22,6 +22,8 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback from homeassistant.helpers import condition @@ -175,6 +177,15 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): async def _async_startup(event): """Init on startup.""" sensor_state = self.hass.states.get(self._sensor_entity_id) + if sensor_state is None or sensor_state.state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): + _LOGGER.debug( + "The sensor state is %s, initialization is delayed", + sensor_state.state if sensor_state is not None else "None", + ) + return await self._async_sensor_changed(self._sensor_entity_id, None, sensor_state) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) From 9f04c234146b076e9e89c36f1eaab849c05c9adb Mon Sep 17 00:00:00 2001 From: Jonas Bergler Date: Tue, 28 Mar 2023 08:40:58 +1300 Subject: [PATCH 0202/1165] Support toggling debug logging for custom components (#90340) Co-authored-by: J. Nick Koston --- homeassistant/components/logger/helpers.py | 11 ++-- tests/components/logger/test_websocket_api.py | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index d85486a41e..df275eaae9 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -66,13 +66,14 @@ def _chattiest_log_level(level1: int, level2: int) -> int: return min(level1, level2) -async def get_integration_loggers(hass: HomeAssistant, domain: str) -> list[str]: +async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]: """Get loggers for an integration.""" - loggers = [f"homeassistant.components.{domain}"] + loggers: set[str] = {f"homeassistant.components.{domain}"} with contextlib.suppress(IntegrationNotFound): integration = await async_get_integration(hass, domain) + loggers.add(integration.pkg_path) if integration.loggers: - loggers.extend(integration.loggers) + loggers.update(integration.loggers) return loggers @@ -188,7 +189,7 @@ class LoggerSettings: if settings.type == LogSettingsType.INTEGRATION: loggers = await get_integration_loggers(hass, domain) else: - loggers = [domain] + loggers = {domain} combined_logs = {logger: LOGSEVERITY[settings.level] for logger in loggers} # Don't override the log levels with the ones from YAML @@ -203,7 +204,7 @@ class LoggerSettings: if settings.type == LogSettingsType.INTEGRATION: loggers = await get_integration_loggers(hass, domain) else: - loggers = [domain] + loggers = {domain} for logger in loggers: combined_logs[logger] = LOGSEVERITY[settings.level] diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index 1252734df9..10c1ceb2f2 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -1,6 +1,8 @@ """Tests for Logger Websocket API commands.""" import logging +from unittest.mock import patch +from homeassistant import loader from homeassistant.components.logger.helpers import async_get_domain_config from homeassistant.components.websocket_api import const from homeassistant.core import HomeAssistant @@ -79,6 +81,55 @@ async def test_integration_log_level( } +async def test_custom_integration_log_level( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser +) -> None: + """Test setting integration log level.""" + websocket_client = await hass_ws_client() + assert await async_setup_component(hass, "logger", {}) + + integration = loader.Integration( + hass, + "custom_components.hue", + None, + { + "name": "Hue", + "dependencies": [], + "requirements": [], + "domain": "hue", + "loggers": ["some_other_logger"], + }, + ) + + with patch( + "homeassistant.components.logger.helpers.async_get_integration", + return_value=integration, + ), patch( + "homeassistant.components.logger.websocket_api.async_get_integration", + return_value=integration, + ): + await websocket_client.send_json( + { + "id": 7, + "type": "logger/integration_log_level", + "integration": "hue", + "level": "DEBUG", + "persistence": "none", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + assert async_get_domain_config(hass).overrides == { + "homeassistant.components.hue": logging.DEBUG, + "custom_components.hue": logging.DEBUG, + "some_other_logger": logging.DEBUG, + } + + async def test_integration_log_level_unknown_integration( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser ) -> None: From fb4b35709dda05920488812551e3723a63dca5f2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 22:19:25 +0200 Subject: [PATCH 0203/1165] Add state translations for helpers (#90356) * Add state translations for helpers * Managed via the UI --- homeassistant/components/counter/strings.json | 2 +- homeassistant/components/group/strings.json | 5 +++ .../components/input_boolean/strings.json | 9 +++++ .../components/input_button/strings.json | 17 ++++++++ .../components/input_datetime/strings.json | 39 ++++++++++++++++++- .../components/input_number/strings.json | 38 +++++++++++++++++- .../components/input_select/strings.json | 21 +++++++++- .../components/input_text/strings.json | 34 +++++++++++++++- .../components/schedule/strings.json | 12 ++++++ homeassistant/components/timer/strings.json | 21 ++++++++++ homeassistant/generated/integrations.json | 2 +- homeassistant/strings.json | 3 +- 12 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/input_button/strings.json diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index fb7d34edf4..548d155408 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -5,7 +5,7 @@ "name": "[%key:component::counter::title%]", "state_attributes": { "editable": { - "name": "UI-managed", + "name": "[%key:common::generic::ui_managed%]", "state": { "true": "[%key:common::state::yes%]", "false": "[%key:common::state::no%]" diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index e78fe982d5..9f50545468 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -169,6 +169,11 @@ "unlocked": "[%key:common::state::unlocked%]", "ok": "[%key:component::binary_sensor::entity_component::problem::state::off%]", "problem": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + }, + "state_attributes": { + "entity_id": { + "name": "Members" + } } } } diff --git a/homeassistant/components/input_boolean/strings.json b/homeassistant/components/input_boolean/strings.json index 8294d72875..d8e1e133f5 100644 --- a/homeassistant/components/input_boolean/strings.json +++ b/homeassistant/components/input_boolean/strings.json @@ -6,6 +6,15 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + } } } } diff --git a/homeassistant/components/input_button/strings.json b/homeassistant/components/input_button/strings.json new file mode 100644 index 0000000000..cfd616fd5e --- /dev/null +++ b/homeassistant/components/input_button/strings.json @@ -0,0 +1,17 @@ +{ + "title": "Input button", + "entity_component": { + "_": { + "name": "[%key:component::input_button::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + } + } + } + } +} diff --git a/homeassistant/components/input_datetime/strings.json b/homeassistant/components/input_datetime/strings.json index 8d51025070..0c3a4b0b0d 100644 --- a/homeassistant/components/input_datetime/strings.json +++ b/homeassistant/components/input_datetime/strings.json @@ -1 +1,38 @@ -{ "title": "Input datetime" } +{ + "title": "Input datetime", + "entity_component": { + "_": { + "name": "[%key:component::input_datetime::title%]", + "state_attributes": { + "day": { + "name": "Day" + }, + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "hour": { + "name": "Hour" + }, + "minute": { + "name": "Minute" + }, + "month": { + "name": "Month" + }, + "second": { + "name": "Second" + }, + "timestamp": { + "name": "Timestamp" + }, + "year": { + "name": "Year" + } + } + } + } +} diff --git a/homeassistant/components/input_number/strings.json b/homeassistant/components/input_number/strings.json index 35bbbebbdd..11ed2f8bf1 100644 --- a/homeassistant/components/input_number/strings.json +++ b/homeassistant/components/input_number/strings.json @@ -1 +1,37 @@ -{ "title": "Input number" } +{ + "title": "Input number", + "entity_component": { + "_": { + "name": "[%key:component::input_number::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "initial": { + "name": "Initial value" + }, + "max": { + "name": "[%key:component::number::entity_component::_::state_attributes::max::name%]" + }, + "min": { + "name": "[%key:component::number::entity_component::_::state_attributes::min::name%]" + }, + "mode": { + "name": "[%key:component::number::entity_component::_::state_attributes::mode::name%]", + "state": { + "auto": "[%key:component::number::entity_component::_::state_attributes::mode::state::auto%]", + "box": "[%key:component::number::entity_component::_::state_attributes::mode::state::box%]", + "slider": "[%key:component::number::entity_component::_::state_attributes::mode::state::slider%]" + } + }, + "step": { + "name": "[%key:component::number::entity_component::_::state_attributes::step::name%]" + } + } + } + } +} diff --git a/homeassistant/components/input_select/strings.json b/homeassistant/components/input_select/strings.json index c3cd5c0c71..f0dead7a1d 100644 --- a/homeassistant/components/input_select/strings.json +++ b/homeassistant/components/input_select/strings.json @@ -1 +1,20 @@ -{ "title": "Input select" } +{ + "title": "Input select", + "entity_component": { + "_": { + "name": "[%key:component::input_select::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "options": { + "name": "[%key:component::select::entity_component::_::state_attributes::options::name%]" + } + } + } + } +} diff --git a/homeassistant/components/input_text/strings.json b/homeassistant/components/input_text/strings.json index dac5995aca..d713c395b6 100644 --- a/homeassistant/components/input_text/strings.json +++ b/homeassistant/components/input_text/strings.json @@ -1 +1,33 @@ -{ "title": "Input text" } +{ + "title": "Input text", + "entity_component": { + "_": { + "name": "[%key:component::input_text::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "max": { + "name": "[%key:component::text::entity_component::_::state_attributes::max::name%]" + }, + "min": { + "name": "[%key:component::text::entity_component::_::state_attributes::min::name%]" + }, + "mode": { + "name": "[%key:component::text::entity_component::_::state_attributes::mode::name%]", + "state": { + "text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]", + "password": "[%key:component::text::entity_component::_::state_attributes::mode::state::password%]" + } + }, + "pattern": { + "name": "[%key:component::text::entity_component::_::state_attributes::pattern::name%]" + } + } + } + } +} diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index f8da366887..4c22e5ecea 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -6,6 +6,18 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "next_event": { + "name": "Next event" + } } } } diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index b6dd2418ad..217de09a53 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -6,6 +6,27 @@ "active": "[%key:common::state::active%]", "idle": "[%key:common::state::idle%]", "paused": "[%key:common::state::paused%]" + }, + "state_attributes": { + "duration": { + "name": "Duration" + }, + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "finishes_at": { + "name": "Finishes at" + }, + "remaining": { + "name": "Remaining" + }, + "restore": { + "name": "Restore" + } } } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4001adbd20..1fb801be12 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6502,7 +6502,6 @@ "config_flow": false }, "input_button": { - "name": "Input Button", "integration_type": "helper", "config_flow": false }, @@ -6578,6 +6577,7 @@ "growatt_server", "homekit_controller", "input_boolean", + "input_button", "input_datetime", "input_number", "input_select", diff --git a/homeassistant/strings.json b/homeassistant/strings.json index ad18b675e0..c4cf0593aa 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -1,7 +1,8 @@ { "common": { "generic": { - "model": "Model" + "model": "Model", + "ui_managed": "Managed via UI" }, "state": { "off": "Off", From 182af87f972dc624784a84e24ffd47b582806873 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 27 Mar 2023 22:21:56 +0200 Subject: [PATCH 0204/1165] Refactor matter device entity value conversion (#90368) --- .../components/matter/binary_sensor.py | 36 ++++++++++++------ homeassistant/components/matter/discovery.py | 3 +- homeassistant/components/matter/entity.py | 11 +++++- homeassistant/components/matter/models.py | 11 +----- homeassistant/components/matter/sensor.py | 38 +++++++++++-------- 5 files changed, 60 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index b4d1b867e7..a82614cbcc 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -1,6 +1,8 @@ """Matter binary sensors.""" from __future__ import annotations +from dataclasses import dataclass + from chip.clusters import Objects as clusters from chip.clusters.Objects import uint from chip.clusters.Types import Nullable, NullValue @@ -15,7 +17,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -30,9 +32,18 @@ async def async_setup_entry( matter.register_platform_handler(Platform.BINARY_SENSOR, async_add_entities) +@dataclass +class MatterBinarySensorEntityDescription( + BinarySensorEntityDescription, MatterEntityDescription +): + """Describe Matter binary sensor entities.""" + + class MatterBinarySensor(MatterEntity, BinarySensorEntity): """Representation of a Matter binary sensor.""" + entity_description: MatterBinarySensorEntityDescription + @callback def _update_from_device(self) -> None: """Update from device.""" @@ -40,7 +51,7 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self._entity_info.measurement_to_ha: + elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) self._attr_is_on = value @@ -51,52 +62,53 @@ DISCOVERY_SCHEMAS = [ # instead of generic occupancy sensor MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="HueMotionSensor", device_class=BinarySensorDeviceClass.MOTION, name="Motion", + measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), vendor_id=(4107,), product_name=("Hue motion sensor",), - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="ContactSensor", device_class=BinarySensorDeviceClass.DOOR, name="Contact", + # value is inverted on matter to what we expect + measurement_to_ha=lambda x: not x, ), entity_class=MatterBinarySensor, required_attributes=(clusters.BooleanState.Attributes.StateValue,), - # value is inverted on matter to what we expect - measurement_to_ha=lambda x: not x, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="OccupancySensor", device_class=BinarySensorDeviceClass.OCCUPANCY, name="Occupancy", + # The first bit = if occupied + measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), - # The first bit = if occupied - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="BatteryChargeLevel", device_class=BinarySensorDeviceClass.BATTERY, name="Battery Status", + measurement_to_ha=lambda x: x + != clusters.PowerSource.Enums.BatChargeLevel.kOk, ), entity_class=MatterBinarySensor, required_attributes=(clusters.PowerSource.Attributes.BatChargeLevel,), # only add binary battery sensor if a regular percentage based is not available absent_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), - measurement_to_ha=lambda x: x != clusters.PowerSource.Enums.BatChargeLevel.kOk, ), ] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 36f415dacc..9df4484e00 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -23,7 +23,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.SENSOR: SENSOR_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, } -SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS.keys()) +SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS) @callback @@ -109,7 +109,6 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, - measurement_to_ha=schema.measurement_to_ha, ) # prevent re-discovery of the same attributes diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index a1d67158ab..bf0a74ef84 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import Callable +from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast @@ -11,7 +12,7 @@ from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -25,6 +26,14 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +@dataclass +class MatterEntityDescription(EntityDescription): + """Describe the Matter entity.""" + + # convert the value from the primary attribute to the value used by HA + measurement_to_ha: Callable[[Any], Any] | None = None + + class MatterEntity(Entity): """Entity class for Matter devices.""" diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index eaa9ccf9a0..3ac7f66b83 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -1,9 +1,7 @@ """Models used for the Matter integration.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass -from typing import Any from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -37,9 +35,6 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type - # [optional] function to call to convert the value from the primary attribute - measurement_to_ha: Callable[[SensorValueTypes], SensorValueTypes] | None = None - @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -50,7 +45,8 @@ class MatterEntityInfo: class MatterDiscoverySchema: """Matter discovery schema. - The Matter endpoint and it's (primary) Attribute for an entity must match these conditions. + The Matter endpoint and its (primary) Attribute + for an entity must match these conditions. """ # specify the hass platform for which this scheme applies (e.g. light, sensor) @@ -95,6 +91,3 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False - - # [optional] function to call to convert the value from the primary attribute - measurement_to_ha: Callable[[Any], Any] | None = None diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 34760fbbf1..84e68695d6 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -1,6 +1,8 @@ """Matter sensors.""" from __future__ import annotations +from dataclasses import dataclass + from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue @@ -22,7 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -37,10 +39,16 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SENSOR, async_add_entities) +@dataclass +class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescription): + """Describe Matter sensor entities.""" + + class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" _attr_state_class = SensorStateClass.MEASUREMENT + entity_description: MatterSensorEntityDescription @callback def _update_from_device(self) -> None: @@ -49,7 +57,7 @@ class MatterSensor(MatterEntity, SensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self._entity_info.measurement_to_ha: + elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) self._attr_native_value = value @@ -58,77 +66,77 @@ class MatterSensor(MatterEntity, SensorEntity): DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="TemperatureSensor", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + measurement_to_ha=lambda x: x / 100, ), entity_class=MatterSensor, required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 100, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="PressureSensor", name="Pressure", native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, + measurement_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 10, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="FlowSensor", name="Flow", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, device_class=SensorDeviceClass.WATER, # what is the device class here ? + measurement_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 10, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="HumiditySensor", name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, + measurement_to_ha=lambda x: x / 100, ), entity_class=MatterSensor, required_attributes=( clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue, ), - measurement_to_ha=lambda x: x / 100, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="LightSensor", name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, + measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), ), entity_class=MatterSensor, required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="PowerSource", name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + # value has double precision + measurement_to_ha=lambda x: int(x / 2), ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), - # value has double precision - measurement_to_ha=lambda x: int(x / 2), ), ] From cb6d384dbafe69b623bedc17940f0932805b0753 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 27 Mar 2023 23:11:49 +0200 Subject: [PATCH 0205/1165] Workday cleanup (#90267) * clean binary sensor * fix const * clean sensor * Fix tests * Clean up --------- Co-authored-by: Martin Hjelmare --- .../components/workday/binary_sensor.py | 66 +-- homeassistant/components/workday/const.py | 2 +- tests/components/workday/__init__.py | 158 ++++++ .../components/workday/test_binary_sensor.py | 460 ++++++------------ 4 files changed, 332 insertions(+), 354 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index cfd04dd30d..a2e7f1e589 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -92,44 +92,38 @@ def setup_platform( sensor_name: str = config[CONF_NAME] workdays: list[str] = config[CONF_WORKDAYS] - year: int = (get_date(dt.now()) + timedelta(days=days_offset)).year + year: int = (dt.now() + timedelta(days=days_offset)).year obj_holidays: HolidayBase = getattr(holidays, country)(years=year) if province: - if ( - hasattr(obj_holidays, "subdivisions") - and province in obj_holidays.subdivisions - ): + try: obj_holidays = getattr(holidays, country)(subdiv=province, years=year) - else: + except NotImplementedError: LOGGER.error("There is no subdivision %s in country %s", province, country) return # Add custom holidays try: obj_holidays.append(add_holidays) - except TypeError: - LOGGER.debug("No custom holidays or invalid holidays") + except ValueError as error: + LOGGER.error("Could not add custom holidays: %s", error) # Remove holidays - try: - for remove_holiday in remove_holidays: - try: - # is this formatted as a date? - if dt.parse_date(remove_holiday): - # remove holiday by date - removed = obj_holidays.pop(remove_holiday) - LOGGER.debug("Removed %s", remove_holiday) - else: - # remove holiday by name - LOGGER.debug("Treating '%s' as named holiday", remove_holiday) - removed = obj_holidays.pop_named(remove_holiday) - for holiday in removed: - LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) - except KeyError as unmatched: - LOGGER.warning("No holiday found matching %s", unmatched) - except TypeError: - LOGGER.debug("No holidays to remove or invalid holidays") + for remove_holiday in remove_holidays: + try: + # is this formatted as a date? + if dt.parse_date(remove_holiday): + # remove holiday by date + removed = obj_holidays.pop(remove_holiday) + LOGGER.debug("Removed %s", remove_holiday) + else: + # remove holiday by name + LOGGER.debug("Treating '%s' as named holiday", remove_holiday) + removed = obj_holidays.pop_named(remove_holiday) + for holiday in removed: + LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) + except KeyError as unmatched: + LOGGER.warning("No holiday found matching %s", unmatched) LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): @@ -143,19 +137,6 @@ def setup_platform( ) -def day_to_string(day: int) -> str | None: - """Convert day index 0 - 7 to string.""" - try: - return ALLOWED_DAYS[day] - except IndexError: - return None - - -def get_date(input_date: date) -> date: - """Return date. Needed for testing.""" - return input_date - - class IsWorkdaySensor(BinarySensorEntity): """Implementation of a Workday sensor.""" @@ -203,12 +184,9 @@ class IsWorkdaySensor(BinarySensorEntity): self._attr_is_on = False # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = get_date(dt.now()) + timedelta(days=self._days_offset) + adjusted_date = dt.now() + timedelta(days=self._days_offset) day = adjusted_date.isoweekday() - 1 - day_of_week = day_to_string(day) - - if day_of_week is None: - return + day_of_week = ALLOWED_DAYS[day] if self.is_include(day_of_week, adjusted_date): self._attr_is_on = True diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py index 9ebf85f1c2..810e1de393 100644 --- a/homeassistant/components/workday/const.py +++ b/homeassistant/components/workday/const.py @@ -5,7 +5,7 @@ import logging from homeassistant.const import WEEKDAYS -LOGGER = logging.getLogger(__name__) +LOGGER = logging.getLogger(__package__) ALLOWED_DAYS = WEEKDAYS + ["holiday"] diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index 57f437e438..80c8f8d584 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -1 +1,159 @@ """Tests the Home Assistant workday binary sensor.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.workday.const import ( + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def init_integration( + hass: HomeAssistant, + config: dict[str, Any], +) -> None: + """Set up the Workday integration in Home Assistant.""" + + await async_setup_component( + hass, "binary_sensor", {"binary_sensor": {"platform": "workday", **config}} + ) + await hass.async_block_till_done() + + +TEST_CONFIG_WITH_PROVINCE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_PROVINCE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "ZZ", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_NO_PROVINCE = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_WITH_STATE = { + "name": DEFAULT_NAME, + "country": "US", + "province": "CA", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_NO_STATE = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_INCLUDE_HOLIDAY = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": ["sat", "sun"], + "days_offset": DEFAULT_OFFSET, + "workdays": ["holiday"], + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_EXAMPLE_1 = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": ["sat", "sun"], + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_EXAMPLE_2 = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": ["mon", "wed", "fri"], + "add_holidays": ["2020-02-24"], + "remove_holidays": [], +} +TEST_CONFIG_REMOVE_HOLIDAY = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["2020-12-25", "2020-11-26"], +} +TEST_CONFIG_REMOVE_NAMED = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["Not a Holiday", "Christmas", "Thanksgiving"], +} +TEST_CONFIG_TOMORROW = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": 1, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_DAY_AFTER_TOMORROW = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": 2, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_YESTERDAY = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": -1, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_ADD_REMOVE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2023-12-32"], + "remove_holidays": ["2023-12-32"], +} diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index f0d2d6b068..89c98a0c67 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -1,350 +1,192 @@ """Tests the Home Assistant workday binary sensor.""" -from datetime import date -from unittest.mock import patch +from datetime import datetime +from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol -import homeassistant.components.workday.binary_sensor as binary_sensor -from homeassistant.setup import setup_component +from homeassistant.components.workday import binary_sensor +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import UTC -from tests.common import assert_setup_component, get_test_home_assistant - -FUNCTION_PATH = "homeassistant.components.workday.binary_sensor.get_date" +from . import ( + TEST_CONFIG_DAY_AFTER_TOMORROW, + TEST_CONFIG_EXAMPLE_1, + TEST_CONFIG_EXAMPLE_2, + TEST_CONFIG_INCLUDE_HOLIDAY, + TEST_CONFIG_INCORRECT_ADD_REMOVE, + TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_NO_PROVINCE, + TEST_CONFIG_NO_STATE, + TEST_CONFIG_REMOVE_HOLIDAY, + TEST_CONFIG_REMOVE_NAMED, + TEST_CONFIG_TOMORROW, + TEST_CONFIG_WITH_PROVINCE, + TEST_CONFIG_WITH_STATE, + TEST_CONFIG_YESTERDAY, + init_integration, +) -class TestWorkdaySetup: - """Test class for workday sensor.""" +async def test_valid_country_yaml() -> None: + """Test valid country from yaml.""" + # Invalid UTF-8, must not contain U+D800 to U+DFFF + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("\ud800") + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("\udfff") + # Country MUST NOT be empty + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("") + # Country must be supported by holidays + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("HomeAssistantLand") - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - # Set valid default config for test - self.config_province = { - "binary_sensor": {"platform": "workday", "country": "DE", "province": "BW"} - } +@pytest.mark.parametrize( + ("config", "expected_state"), + [ + (TEST_CONFIG_WITH_PROVINCE, "off"), + (TEST_CONFIG_NO_PROVINCE, "off"), + (TEST_CONFIG_WITH_STATE, "on"), + (TEST_CONFIG_NO_STATE, "on"), + (TEST_CONFIG_EXAMPLE_1, "on"), + (TEST_CONFIG_EXAMPLE_2, "off"), + (TEST_CONFIG_TOMORROW, "off"), + (TEST_CONFIG_DAY_AFTER_TOMORROW, "off"), + (TEST_CONFIG_YESTERDAY, "on"), + ], +) +async def test_setup( + hass: HomeAssistant, + config: dict[str, Any], + expected_state: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2022, 4, 15, 12, tzinfo=UTC)) # Monday + await init_integration(hass, config) - self.config_noprovince = { - "binary_sensor": {"platform": "workday", "country": "DE"} - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == expected_state + assert state.attributes == { + "friendly_name": "Workday Sensor", + "workdays": config["workdays"], + "excludes": config["excludes"], + "days_offset": config["days_offset"], + } - self.config_invalidprovince = { + +async def test_setup_with_invalid_province_from_yaml(hass: HomeAssistant) -> None: + """Test setup invalid province with import.""" + + await async_setup_component( + hass, + "binary_sensor", + { "binary_sensor": { "platform": "workday", "country": "DE", "province": "invalid", } - } + }, + ) + await hass.async_block_till_done() - self.config_state = { - "binary_sensor": {"platform": "workday", "country": "US", "province": "CA"} - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state is None - self.config_nostate = { - "binary_sensor": {"platform": "workday", "country": "US"} - } - self.config_includeholiday = { - "binary_sensor": { - "platform": "workday", - "country": "DE", - "province": "BW", - "workdays": ["holiday"], - "excludes": ["sat", "sun"], - } - } +async def test_setup_with_working_holiday( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCLUDE_HOLIDAY) - self.config_example1 = { - "binary_sensor": { - "platform": "workday", - "country": "US", - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "excludes": ["sat", "sun"], - } - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" - self.config_example2 = { - "binary_sensor": { - "platform": "workday", - "country": "DE", - "province": "BW", - "workdays": ["mon", "wed", "fri"], - "excludes": ["sat", "sun", "holiday"], - "add_holidays": ["2020-02-24"], - } - } - self.config_remove_holidays = { - "binary_sensor": { - "platform": "workday", - "country": "US", - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "excludes": ["sat", "sun", "holiday"], - "remove_holidays": ["2020-12-25", "2020-11-26"], - } - } +async def test_setup_add_holiday( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2020, 2, 24, 12, tzinfo=UTC)) # Monday + await init_integration(hass, TEST_CONFIG_EXAMPLE_2) - self.config_remove_named_holidays = { - "binary_sensor": { - "platform": "workday", - "country": "US", - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "excludes": ["sat", "sun", "holiday"], - "remove_holidays": ["Not a Holiday", "Christmas", "Thanksgiving"], - } - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "off" - self.config_tomorrow = { - "binary_sensor": {"platform": "workday", "country": "DE", "days_offset": 1} - } - self.config_day_after_tomorrow = { - "binary_sensor": {"platform": "workday", "country": "DE", "days_offset": 2} - } +async def test_setup_remove_holiday( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2020, 12, 25, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_REMOVE_HOLIDAY) - self.config_yesterday = { - "binary_sensor": {"platform": "workday", "country": "DE", "days_offset": -1} - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - def test_valid_country(self): - """Test topic name/filter validation.""" - # Invalid UTF-8, must not contain U+D800 to U+DFFF - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\ud800") - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\udfff") - # Country MUST NOT be empty - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("") - # Country must be supported by holidays - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("HomeAssistantLand") +async def test_setup_remove_holiday_named( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2020, 12, 25, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_REMOVE_NAMED) - # Valid country code validation must not raise an exception - for country in ("IM", "LI", "US"): - assert binary_sensor.valid_country(country) == country + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" - def test_setup_component_province(self): - """Set up workday component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity is not None +async def test_setup_day_after_tomorrow( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2022, 5, 27, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_DAY_AFTER_TOMORROW) - # Freeze time to a workday - Mar 15th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 15)) - def test_workday_province(self, mock_date): - """Test if workdays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "off" - self.hass.start() - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" +async def test_setup_faulty_province( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with faulty province.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) - # Freeze time to a weekend - Mar 12th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 12)) - def test_weekend_province(self, mock_date): - """Test if weekends are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() + state = hass.states.get("binary_sensor.workday_sensor") + assert state is None - self.hass.start() + assert "There is no subdivision" in caplog.text - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - # Freeze time to a public holiday in province BW - Jan 6th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) - def test_public_holiday_province(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() +async def test_setup_incorrect_add_remove( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with incorrect add/remove custom holiday.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_ADD_REMOVE) - self.hass.start() + hass.states.get("binary_sensor.workday_sensor") - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - def test_setup_component_noprovince(self): - """Set up workday component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_noprovince) - self.hass.block_till_done() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity is not None - - # Freeze time to a public holiday in province BW - Jan 6th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) - def test_public_holiday_noprovince(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_noprovince) - self.hass.block_till_done() - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a public holiday in state CA - Mar 31st, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 31)) - def test_public_holiday_state(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_state) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - # Freeze time to a public holiday in state CA - Mar 31st, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 31)) - def test_public_holiday_nostate(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_nostate) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - def test_setup_component_invalidprovince(self): - """Set up workday component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_invalidprovince) - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity is None - - # Freeze time to a public holiday in province BW - Jan 6th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) - def test_public_holiday_includeholiday(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_includeholiday) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a saturday to test offset - Aug 5th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) - def test_tomorrow(self, mock_date): - """Test if tomorrow are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_tomorrow) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - # Freeze time to a saturday to test offset - Aug 5th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) - def test_day_after_tomorrow(self, mock_date): - """Test if the day after tomorrow are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_day_after_tomorrow) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a saturday to test offset - Aug 5th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) - def test_yesterday(self, mock_date): - """Test if yesterday are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_yesterday) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a Presidents day to test Holiday on a Work day - Jan 20th, 2020 - # Presidents day Feb 17th 2020 is mon. - @patch(FUNCTION_PATH, return_value=date(2020, 2, 17)) - def test_config_example1_holiday(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_example1) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to test tue - Feb 18th, 2020 - @patch(FUNCTION_PATH, return_value=date(2020, 2, 18)) - def test_config_example2_tue(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_example2) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - # Freeze time to test mon, but added as holiday - Feb 24th, 2020 - @patch(FUNCTION_PATH, return_value=date(2020, 2, 24)) - def test_config_example2_add_holiday(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_example2) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - def test_day_to_string(self): - """Test if day_to_string is behaving correctly.""" - assert binary_sensor.day_to_string(0) == "mon" - assert binary_sensor.day_to_string(1) == "tue" - assert binary_sensor.day_to_string(7) == "holiday" - assert binary_sensor.day_to_string(8) is None - - # Freeze time to test Fri, but remove holiday - December 25, 2020 - @patch(FUNCTION_PATH, return_value=date(2020, 12, 25)) - def test_config_remove_holidays_xmas(self, mock_date): - """Test if removed holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_remove_holidays) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to test Fri, but remove holiday by name - Christmas - @patch(FUNCTION_PATH, return_value=date(2020, 12, 25)) - def test_config_remove_named_holidays_xmas(self, mock_date): - """Test if removed by name holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component( - self.hass, "binary_sensor", self.config_remove_named_holidays - ) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" + assert ( + "Could not add custom holidays: Cannot parse date from string '2023-12-32'" + in caplog.text + ) + assert "No holiday found matching '2023-12-32'" in caplog.text From 058a2c9d83870f81af88632403aa93f9abab676a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 12:41:51 -1000 Subject: [PATCH 0206/1165] Bump yalexs-ble to 2.1.12 (#90381) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 7884ba6a4b..5528b79353 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.9"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.12"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 5c7adf09e3..6cff0dd8c6 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.9"] + "requirements": ["yalexs-ble==2.1.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 91238f06f2..76444267b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.9 +yalexs-ble==2.1.12 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f16cbf7c37..451b2353a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1908,7 +1908,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.9 +yalexs-ble==2.1.12 # homeassistant.components.august yalexs==1.2.7 From 1cd2fe9d28f1cf60c2f7987dce5fbf7344153571 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 12:44:34 -1000 Subject: [PATCH 0207/1165] Bump aiodiscover to 1.4.15 (#90383) --- homeassistant/components/dhcp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index a5ee449dda..2e1d758746 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.4.14"] + "requirements": ["scapy==2.5.0", "aiodiscover==1.4.15"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ae190115b2..518bea69fb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,7 +1,7 @@ PyJWT==2.6.0 PyNaCl==1.5.0 PyTurboJPEG==1.6.7 -aiodiscover==1.4.14 +aiodiscover==1.4.15 aiohttp==3.8.4 aiohttp_cors==0.7.0 astral==2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 76444267b4..77974c3ad7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -137,7 +137,7 @@ aiobafi6==0.8.0 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.14 +aiodiscover==1.4.15 # homeassistant.components.dnsip # homeassistant.components.minecraft_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 451b2353a6..cfb677ed57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -127,7 +127,7 @@ aiobafi6==0.8.0 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.14 +aiodiscover==1.4.15 # homeassistant.components.dnsip # homeassistant.components.minecraft_server From a361fba8f5859be3e60943c4011a8bdbde26db1b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Mar 2023 00:48:14 +0200 Subject: [PATCH 0208/1165] Bump nextcloudmonitor to 1.4.0 (#90372) --- homeassistant/components/nextcloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextcloud/manifest.json b/homeassistant/components/nextcloud/manifest.json index 72e992277c..fe4366c334 100644 --- a/homeassistant/components/nextcloud/manifest.json +++ b/homeassistant/components/nextcloud/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nextcloud", "iot_class": "cloud_polling", - "requirements": ["nextcloudmonitor==1.1.0"] + "requirements": ["nextcloudmonitor==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 77974c3ad7..c2838240e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1189,7 +1189,7 @@ neurio==0.3.1 nexia==2.0.6 # homeassistant.components.nextcloud -nextcloudmonitor==1.1.0 +nextcloudmonitor==1.4.0 # homeassistant.components.discord nextcord==2.0.0a8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cfb677ed57..2759d786e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -888,7 +888,7 @@ nettigo-air-monitor==2.1.0 nexia==2.0.6 # homeassistant.components.nextcloud -nextcloudmonitor==1.1.0 +nextcloudmonitor==1.4.0 # homeassistant.components.discord nextcord==2.0.0a8 From 59113a3e4c368d01a0f047de9fa17d617a7d58b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 12:50:11 -1000 Subject: [PATCH 0209/1165] Bump flux_led to 0.28.36 (#90380) --- homeassistant/components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 5bb47fbe79..a9b1ef61db 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -51,5 +51,5 @@ "iot_class": "local_push", "loggers": ["flux_led"], "quality_scale": "platinum", - "requirements": ["flux_led==0.28.35"] + "requirements": ["flux_led==0.28.36"] } diff --git a/requirements_all.txt b/requirements_all.txt index c2838240e2..51d948a2de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -725,7 +725,7 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux_led==0.28.35 +flux_led==0.28.36 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2759d786e8..ceafe5d173 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -553,7 +553,7 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux_led==0.28.35 +flux_led==0.28.36 # homeassistant.components.homekit # homeassistant.components.recorder From 5b4663d2ca729545635db0e4fc29b68ec961b2b7 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Mon, 27 Mar 2023 20:27:54 -0300 Subject: [PATCH 0210/1165] Bump proxmoxer to 2.0.1 in Proxmox VE (#90378) --- homeassistant/components/proxmoxve/__init__.py | 3 +-- homeassistant/components/proxmoxve/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index f8e350f2b1..7ea4cac58d 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -3,8 +3,7 @@ from __future__ import annotations from datetime import timedelta -from proxmoxer import ProxmoxAPI -from proxmoxer.backends.https import AuthenticationError +from proxmoxer import AuthenticationError, ProxmoxAPI from proxmoxer.core import ResourceException import requests.exceptions from requests.exceptions import ConnectTimeout, SSLError diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json index 1c68069579..8cf3bc7932 100644 --- a/homeassistant/components/proxmoxve/manifest.json +++ b/homeassistant/components/proxmoxve/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/proxmoxve", "iot_class": "local_polling", "loggers": ["proxmoxer"], - "requirements": ["proxmoxer==1.3.1"] + "requirements": ["proxmoxer==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 51d948a2de..e0df5121a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1397,7 +1397,7 @@ proliphix==0.4.1 prometheus_client==0.7.1 # homeassistant.components.proxmoxve -proxmoxer==1.3.1 +proxmoxer==2.0.1 # homeassistant.components.hardware psutil-home-assistant==0.0.1 From e27d3c952396b52c5afe714f8379df2bf065d052 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Tue, 28 Mar 2023 06:18:47 +0000 Subject: [PATCH 0211/1165] Improve airq handling of DeviceInfo (#90232) * Reduce data sharing between ConfigFlow and DataUpdateCoordinator Instead of fetching device information from the device once in `ConfigFlow` and then piping it through in `ConfigEntry.data`, only use as much as needed in `ConfigFlow.async_step_user`, then fetch again in `AirQCoordinator._async_update_data` if a key is missing. Additionally, factor `AirQCoordinator` out into a sumbodule. Add a simple test for `AirQCoordinator.device_info` update. Positive side effect: `AirQCoordinator.device_info` is updated explicitly, instead of dumping the entire content of (a fully compatible) `TypedDict`, retrieved from `aioairq`. * Remove tests ill-suited to this PR `test_config_flow.test_duplicate_error` slipped through by mistake, while `test_coordinator.test_fetch_device_info_on_first_update` may need a more thoroughly suite of accompanying tests * Ignore airq/coordinator.py ...newly separated from airq/__init__.py, that's already in this list * Reorder files alphabetically --- .coveragerc | 1 + homeassistant/components/airq/__init__.py | 48 +-------------- homeassistant/components/airq/config_flow.py | 5 +- homeassistant/components/airq/coordinator.py | 61 ++++++++++++++++++++ tests/components/airq/test_config_flow.py | 5 +- 5 files changed, 68 insertions(+), 52 deletions(-) create mode 100644 homeassistant/components/airq/coordinator.py diff --git a/.coveragerc b/.coveragerc index da7cc42ba1..520b87b08b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,6 +36,7 @@ omit = homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py homeassistant/components/airq/__init__.py + homeassistant/components/airq/coordinator.py homeassistant/components/airq/sensor.py homeassistant/components/airthings/__init__.py homeassistant/components/airthings/sensor.py diff --git a/homeassistant/components/airq/__init__.py b/homeassistant/components/airq/__init__.py index 4bc64e1e82..06d7ba3074 100644 --- a/homeassistant/components/airq/__init__.py +++ b/homeassistant/components/airq/__init__.py @@ -1,58 +1,16 @@ """The air-Q integration.""" from __future__ import annotations -from datetime import timedelta -import logging - -from aioairq import AirQ - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import AirQCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -class AirQCoordinator(DataUpdateCoordinator): - """Coordinator is responsible for querying the device at a specified route.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - ) -> None: - """Initialise a custom coordinator.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=UPDATE_INTERVAL), - ) - session = async_get_clientsession(hass) - self.airq = AirQ( - entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session - ) - self.device_id = entry.unique_id - assert self.device_id is not None - self.device_info = DeviceInfo( - manufacturer=MANUFACTURER, - identifiers={(DOMAIN, self.device_id)}, - ) - self.device_info.update(entry.data["device_info"]) - - async def _async_update_data(self) -> dict: - """Fetch the data from the device.""" - data = await self.airq.get(TARGET_ROUTE) - return self.airq.drop_uncertainties_from_data(data) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up air-Q from a config entry.""" diff --git a/homeassistant/components/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index 90a6b9e055..41eda912e9 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -74,12 +74,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) device_info = await airq.fetch_device_info() - await self.async_set_unique_id(device_info.pop("id")) + await self.async_set_unique_id(device_info["id"]) self._abort_if_unique_id_configured() return self.async_create_entry( - title=device_info["name"], - data=user_input | {"device_info": device_info}, + title=device_info["name"], data=user_input ) return self.async_show_form( diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py new file mode 100644 index 0000000000..78e9580c63 --- /dev/null +++ b/homeassistant/components/airq/coordinator.py @@ -0,0 +1,61 @@ +"""The air-Q integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioairq import AirQ + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class AirQCoordinator(DataUpdateCoordinator): + """Coordinator is responsible for querying the device at a specified route.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialise a custom coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + session = async_get_clientsession(hass) + self.airq = AirQ( + entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session + ) + self.device_id = entry.unique_id + assert self.device_id is not None + self.device_info = DeviceInfo( + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, self.device_id)}, + ) + + async def _async_update_data(self) -> dict: + """Fetch the data from the device.""" + if "name" not in self.device_info: + info = await self.airq.fetch_device_info() + self.device_info.update( + DeviceInfo( + name=info["name"], + model=info["model"], + sw_version=info["sw_version"], + hw_version=info["hw_version"], + ) + ) + + data = await self.airq.get(TARGET_ROUTE) + return self.airq.drop_uncertainties_from_data(data) diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 52bd5cd37f..af71dc813e 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -24,9 +24,6 @@ TEST_DEVICE_INFO = DeviceInfo( sw_version="sw", hw_version="hw", ) -TEST_DATA_OUT = TEST_USER_DATA | { - "device_info": {k: v for k, v in TEST_DEVICE_INFO.items() if k != "id"} -} async def test_form(hass: HomeAssistant) -> None: @@ -48,7 +45,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_DEVICE_INFO["name"] - assert result2["data"] == TEST_DATA_OUT + assert result2["data"] == TEST_USER_DATA async def test_form_invalid_auth(hass: HomeAssistant) -> None: From 3dd3cb195fe64ecda642d6a811ce8a9d5a832108 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 27 Mar 2023 19:23:53 -1100 Subject: [PATCH 0212/1165] Set default value for some Fronius entities (#89475) --- .../components/fronius/coordinator.py | 4 +- homeassistant/components/fronius/sensor.py | 199 ++++++++++-------- tests/components/fronius/test_sensor.py | 26 ++- 3 files changed, 137 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index 16e55f1272..94fd5f256a 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any, TypeVar from pyfronius import BadStatusError, FroniusError -from homeassistant.components.sensor import SensorEntityDescription from homeassistant.core import callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,6 +24,7 @@ from .sensor import ( OHMPILOT_ENTITY_DESCRIPTIONS, POWER_FLOW_ENTITY_DESCRIPTIONS, STORAGE_ENTITY_DESCRIPTIONS, + FroniusSensorEntityDescription, ) if TYPE_CHECKING: @@ -41,7 +41,7 @@ class FroniusCoordinatorBase( default_interval: timedelta error_interval: timedelta - valid_descriptions: list[SensorEntityDescription] + valid_descriptions: list[FroniusSensorEntityDescription] MAX_FAILED_UPDATES = 3 diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 8c7055db8b..e7f9389537 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -1,6 +1,7 @@ """Support for Fronius devices.""" from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final from homeassistant.components.sensor import ( @@ -25,6 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -77,113 +79,128 @@ async def async_setup_entry( ) -INVERTER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +@dataclass +class FroniusSensorEntityDescription(SensorEntityDescription): + """Describes Fronius sensor entity.""" + + default_value: StateType | None = None + + +INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_day", name="Energy day", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_year", name="Energy year", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_total", name="Energy total", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="frequency_ac", name="Frequency AC", + default_value=0, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac", name="Current AC", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc", name="Current DC", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc_2", name="Current DC 2", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_ac", name="Power AC", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac", name="Voltage AC", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc", name="Voltage DC", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_2", name="Voltage DC 2", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), # device status entities - SensorEntityDescription( + FroniusSensorEntityDescription( key="inverter_state", name="Inverter state", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="error_code", name="Error code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="status_code", name="Status code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="led_state", name="LED state", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="led_color", name="LED color", entity_category=EntityCategory.DIAGNOSTIC, @@ -191,20 +208,20 @@ INVERTER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ ), ] -LOGGER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +LOGGER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="co2_factor", name="CO₂ factor", state_class=SensorStateClass.MEASUREMENT, icon="mdi:molecule-co2", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="cash_factor", name="Grid export tariff", state_class=SensorStateClass.MEASUREMENT, icon="mdi:cash-plus", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="delivery_factor", name="Grid import tariff", state_class=SensorStateClass.MEASUREMENT, @@ -212,8 +229,8 @@ LOGGER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ ), ] -METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="current_ac_phase_1", name="Current AC phase 1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -221,7 +238,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac_phase_2", name="Current AC phase 2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -229,7 +246,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac_phase_3", name="Current AC phase 3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -237,7 +254,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_reactive_ac_consumed", name="Energy reactive AC consumed", native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, @@ -245,7 +262,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_reactive_ac_produced", name="Energy reactive AC produced", native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, @@ -253,7 +270,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_ac_minus", name="Energy real AC minus", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -261,7 +278,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_ac_plus", name="Energy real AC plus", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -269,33 +286,33 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_consumed", name="Energy real consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_produced", name="Energy real produced", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="frequency_phase_average", name="Frequency phase average", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="meter_location", name="Meter location", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_1", name="Power apparent phase 1", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -304,7 +321,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_2", name="Power apparent phase 2", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -313,7 +330,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_3", name="Power apparent phase 3", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -322,7 +339,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent", name="Power apparent", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -331,34 +348,34 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_1", name="Power factor phase 1", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_2", name="Power factor phase 2", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_3", name="Power factor phase 3", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor", name="Power factor", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_1", name="Power reactive phase 1", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -367,7 +384,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_2", name="Power reactive phase 2", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -376,7 +393,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_3", name="Power reactive phase 3", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -385,7 +402,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive", name="Power reactive", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -394,7 +411,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_1", name="Power real phase 1", native_unit_of_measurement=UnitOfPower.WATT, @@ -402,7 +419,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_2", name="Power real phase 2", native_unit_of_measurement=UnitOfPower.WATT, @@ -410,7 +427,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_3", name="Power real phase 3", native_unit_of_measurement=UnitOfPower.WATT, @@ -418,14 +435,14 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real", name="Power real", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_1", name="Voltage AC phase 1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -433,7 +450,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_2", name="Voltage AC phase 2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -441,7 +458,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_3", name="Voltage AC phase 3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -449,7 +466,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_12", name="Voltage AC phase 1-2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -457,7 +474,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_23", name="Voltage AC phase 2-3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -465,7 +482,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_31", name="Voltage AC phase 3-1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -475,47 +492,47 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ ), ] -OHMPILOT_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_real_ac_consumed", name="Energy consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_ac", name="Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="temperature_channel_1", name="Temperature channel 1", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="error_code", name="Error code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_code", name="State code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_message", name="State message", entity_category=EntityCategory.DIAGNOSTIC, ), ] -POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_day", name="Energy day", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -523,7 +540,7 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_year", name="Energy year", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -531,7 +548,7 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_total", name="Energy total", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -539,69 +556,75 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="meter_mode", name="Meter mode", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_battery", name="Power battery", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_grid", name="Power grid", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_load", name="Power load", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_photovoltaics", name="Power photovoltaics", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="relative_autonomy", name="Relative autonomy", + default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:home-circle-outline", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="relative_self_consumption", name="Relative self consumption", + default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:solar-power", ), ] -STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="capacity_maximum", name="Capacity maximum", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="capacity_designed", name="Capacity designed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc", name="Current DC", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -609,7 +632,7 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc", name="Voltage DC", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -617,7 +640,7 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_maximum_cell", name="Voltage DC maximum cell", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -626,7 +649,7 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:current-dc", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_minimum_cell", name="Voltage DC minimum cell", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -635,14 +658,14 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:current-dc", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_of_charge", name="State of charge", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="temperature_cell", name="Temperature cell", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -655,7 +678,8 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEntity): """Defines a Fronius coordinator entity.""" - entity_descriptions: list[SensorEntityDescription] + entity_description: FroniusSensorEntityDescription + entity_descriptions: list[FroniusSensorEntityDescription] _attr_has_entity_name = True @@ -682,7 +706,11 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn new_value = self.coordinator.data[self.solar_net_id][ self.entity_description.key ]["value"] - return round(new_value, 4) if isinstance(new_value, float) else new_value + if new_value is None: + return self.entity_description.default_value + if isinstance(new_value, float): + return round(new_value, 4) + return new_value @callback def _handle_coordinator_update(self) -> None: @@ -690,7 +718,8 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn try: self._attr_native_value = self._get_entity_value() except KeyError: - return + # sets state to `None` if no default_value is defined in entity description + self._attr_native_value = self.entity_description.default_value self.async_write_ha_state() diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 6f7b779388..ef881b552f 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -62,16 +62,16 @@ async def test_symo_inverter( assert_state("sensor.symo_20_power_ac", 1190) assert_state("sensor.symo_20_voltage_ac", 227.90) - # Third test at nighttime - additional AC entities aren't changed + # Third test at nighttime - additional AC entities default to 0 mock_responses(aioclient_mock, night=True) async_fire_time_changed( hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() - assert_state("sensor.symo_20_current_ac", 5.19) - assert_state("sensor.symo_20_frequency_ac", 49.94) - assert_state("sensor.symo_20_power_ac", 1190) - assert_state("sensor.symo_20_voltage_ac", 227.90) + assert_state("sensor.symo_20_current_ac", 0) + assert_state("sensor.symo_20_frequency_ac", 0) + assert_state("sensor.symo_20_power_ac", 0) + assert_state("sensor.symo_20_voltage_ac", 0) async def test_symo_logger( @@ -190,6 +190,22 @@ async def test_symo_power_flow( assert_state("sensor.solarnet_relative_autonomy", 39.4708) assert_state("sensor.solarnet_relative_self_consumption", 100) + # Third test at nighttime - default values are used + mock_responses(aioclient_mock, night=True) + async_fire_time_changed( + hass, dt.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 + assert_state("sensor.solarnet_energy_day", 10828) + assert_state("sensor.solarnet_energy_total", 44186900) + assert_state("sensor.solarnet_energy_year", 25507686) + assert_state("sensor.solarnet_power_grid", 975.31) + assert_state("sensor.solarnet_power_load", -975.31) + assert_state("sensor.solarnet_power_photovoltaics", 0) + assert_state("sensor.solarnet_relative_autonomy", 0) + assert_state("sensor.solarnet_relative_self_consumption", 0) + async def test_gen24(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test Fronius Gen24 inverter entities.""" From dc37d921972e444a7fcb88441dabd0d7947a19b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 20:34:56 -1000 Subject: [PATCH 0213/1165] Add lru stats to the profiler integration (#90388) --- homeassistant/components/profiler/__init__.py | 72 ++++++++++++++++++- .../components/profiler/services.yaml | 3 + tests/components/profiler/test_init.py | 32 +++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index fab6932edd..b838f67d02 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -1,6 +1,8 @@ """The profiler integration.""" import asyncio +from contextlib import suppress from datetime import timedelta +from functools import _lru_cache_wrapper import logging import reprlib import sys @@ -9,6 +11,7 @@ import time import traceback from typing import Any, cast +from lru import LRU # pylint: disable=no-name-in-module import voluptuous as vol from homeassistant.components import persistent_notification @@ -27,9 +30,21 @@ SERVICE_MEMORY = "memory" SERVICE_START_LOG_OBJECTS = "start_log_objects" SERVICE_STOP_LOG_OBJECTS = "stop_log_objects" SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects" +SERVICE_LRU_STATS = "lru_stats" SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" +_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ + +_KNOWN_LRU_CLASSES = ( + "EventDataManager", + "EventTypeManager", + "StatesMetaManager", + "StateAttributesManager", + "StatisticsMetaManager", + "DomainData", + "IntegrationMatcher", +) SERVICES = ( SERVICE_START, @@ -37,6 +52,7 @@ SERVICES = ( SERVICE_START_LOG_OBJECTS, SERVICE_STOP_LOG_OBJECTS, SERVICE_DUMP_LOG_OBJECTS, + SERVICE_LRU_STATS, SERVICE_LOG_THREAD_FRAMES, SERVICE_LOG_EVENT_LOOP_SCHEDULED, ) @@ -47,6 +63,7 @@ CONF_SECONDS = "seconds" LOG_INTERVAL_SUB = "log_interval_subscription" + _LOGGER = logging.getLogger(__name__) @@ -123,6 +140,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: notification_id="profile_object_dump", ) + def _get_function_absfile(func: Any) -> str: + """Get the absolute file path of a function.""" + import inspect # pylint: disable=import-outside-toplevel + + abs_file = "unknown" + with suppress(Exception): + abs_file = inspect.getabsfile(func) + return abs_file + + def _lru_stats(call: ServiceCall) -> None: + """Log the stats of all lru caches.""" + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # pylint: disable=import-outside-toplevel + + for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): + lru = cast(_lru_cache_wrapper, lru) + _LOGGER.critical( + "Cache stats for lru_cache %s at %s: %s", + lru.__wrapped__, + _get_function_absfile(lru.__wrapped__), + lru.cache_info(), + ) + + for _class in _KNOWN_LRU_CLASSES: + for class_with_lru_attr in objgraph.by_type(_class): + for maybe_lru in class_with_lru_attr.__dict__.values(): + if isinstance(maybe_lru, LRU): + _LOGGER.critical( + "Cache stats for LRU %s at %s: %s", + type(class_with_lru_attr), + _get_function_absfile(class_with_lru_attr), + maybe_lru.get_stats(), + ) + + persistent_notification.create( + hass, + ( + "LRU cache states have been dumped to the log. See [the" + " logs](/config/logs) to review the stats." + ), + title="LRU stats completed", + notification_id="profile_lru_stats", + ) + async def _async_dump_thread_frames(call: ServiceCall) -> None: """Log all thread frames.""" frames = sys._current_frames() # pylint: disable=protected-access @@ -202,6 +265,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=vol.Schema({vol.Required(CONF_TYPE): str}), ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LRU_STATS, + _lru_stats, + ) + async_register_admin_service( hass, DOMAIN, @@ -323,4 +393,4 @@ def _log_objects(*_): # integration is used at a time import objgraph # pylint: disable=import-outside-toplevel - _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=100)) + _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) diff --git a/homeassistant/components/profiler/services.yaml b/homeassistant/components/profiler/services.yaml index 8d9ae35ed1..1105842891 100644 --- a/homeassistant/components/profiler/services.yaml +++ b/homeassistant/components/profiler/services.yaml @@ -51,6 +51,9 @@ dump_log_objects: example: State selector: text: +lru_stats: + name: Log LRU stats + description: Log the stats of all lru caches. log_thread_frames: name: Log thread frames description: Log the current frames for all threads. diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 0f46f306fe..2c283463b6 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -1,9 +1,11 @@ """Test the Profiler config flow.""" from datetime import timedelta +from functools import lru_cache import os import sys from unittest.mock import patch +from lru import LRU # pylint: disable=no-name-in-module import py import pytest @@ -12,6 +14,7 @@ from homeassistant.components.profiler import ( SERVICE_DUMP_LOG_OBJECTS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, SERVICE_LOG_THREAD_FRAMES, + SERVICE_LRU_STATS, SERVICE_MEMORY, SERVICE_START, SERVICE_START_LOG_OBJECTS, @@ -228,3 +231,32 @@ async def test_log_scheduled( assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test logging lru stats.""" + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + @lru_cache(maxsize=1) + def _dummy_test_lru_stats(): + return 1 + + class DomainData: + def __init__(self): + self._data = LRU(1) + + domain_data = DomainData() + assert hass.services.has_service(DOMAIN, SERVICE_LRU_STATS) + + await hass.services.async_call(DOMAIN, SERVICE_LRU_STATS, blocking=True) + + assert "DomainData" in caplog.text + assert "(0, 0)" in caplog.text + assert "_dummy_test_lru_stats" in caplog.text + assert "CacheInfo" in caplog.text + del domain_data From 33fef5592fba45117bf6555385a20d5ed58cc56b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 28 Mar 2023 08:36:42 +0200 Subject: [PATCH 0214/1165] Refactor GIOS sensor platform (#89389) --- homeassistant/components/gios/const.py | 3 - homeassistant/components/gios/sensor.py | 143 ++++++---- homeassistant/components/gios/strings.json | 50 ++++ tests/components/gios/test_sensor.py | 314 ++++++++++----------- 4 files changed, 300 insertions(+), 210 deletions(-) diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 895775495f..33ddfae6fe 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -16,9 +16,6 @@ URL = "http://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id API_TIMEOUT: Final = 30 -ATTR_INDEX: Final = "index" -ATTR_STATION: Final = "station" - ATTR_C6H6: Final = "c6h6" ATTR_CO: Final = "co" ATTR_NO2: Final = "no2" diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 9c73b35889..7cf4b7e7c6 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, cast + +from gios.model import GiosSensors from homeassistant.components.sensor import ( DOMAIN as PLATFORM, @@ -14,11 +15,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_NAME, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONF_NAME, -) +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType @@ -32,13 +29,11 @@ from .const import ( ATTR_AQI, ATTR_C6H6, ATTR_CO, - ATTR_INDEX, ATTR_NO2, ATTR_O3, ATTR_PM10, ATTR_PM25, ATTR_SO2, - ATTR_STATION, ATTRIBUTION, DOMAIN, MANUFACTURER, @@ -49,17 +44,24 @@ _LOGGER = logging.getLogger(__name__) @dataclass -class GiosSensorEntityDescription(SensorEntityDescription): +class GiosSensorRequiredKeysMixin: + """Class for GIOS entity required keys.""" + + value: Callable[[GiosSensors], StateType] + + +@dataclass +class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKeysMixin): """Class describing GIOS sensor entities.""" - value: Callable | None = round + subkey: str | None = None SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_AQI, name="AQI", - value=None, + value=lambda sensors: sensors.aqi.value if sensors.aqi else None, icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], @@ -68,6 +70,8 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_C6H6, name="C6H6", + value=lambda sensors: sensors.c6h6.value if sensors.c6h6 else None, + suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -75,44 +79,107 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_CO, name="CO", + value=lambda sensors: sensors.co.value if sensors.co else None, + suggested_display_precision=0, + icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_NO2, name="NO2", + value=lambda sensors: sensors.no2.value if sensors.no2 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_NO2, + subkey="index", + name="NO2 index", + value=lambda sensors: sensors.no2.index if sensors.no2 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="no2_index", + ), GiosSensorEntityDescription( key=ATTR_O3, name="O3", + value=lambda sensors: sensors.o3.value if sensors.o3 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_O3, + subkey="index", + name="O3 index", + value=lambda sensors: sensors.o3.index if sensors.o3 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="o3_index", + ), GiosSensorEntityDescription( key=ATTR_PM10, name="PM10", + value=lambda sensors: sensors.pm10.value if sensors.pm10 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_PM10, + subkey="index", + name="PM10 index", + value=lambda sensors: sensors.pm10.index if sensors.pm10 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="pm10_index", + ), GiosSensorEntityDescription( key=ATTR_PM25, name="PM2.5", + value=lambda sensors: sensors.pm25.value if sensors.pm25 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_PM25, + subkey="index", + name="PM2.5 index", + value=lambda sensors: sensors.pm25.index if sensors.pm25 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="pm25_index", + ), GiosSensorEntityDescription( key=ATTR_SO2, name="SO2", + value=lambda sensors: sensors.so2.value if sensors.so2 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_SO2, + subkey="index", + name="SO2 index", + value=lambda sensors: sensors.so2.index if sensors.so2 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="so2_index", + ), ) @@ -140,15 +207,13 @@ async def async_setup_entry( ) entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) - sensors: list[GiosSensor | GiosAqiSensor] = [] + sensors: list[GiosSensor] = [] for description in SENSOR_TYPES: if getattr(coordinator.data, description.key) is None: continue - if description.key == ATTR_AQI: - sensors.append(GiosAqiSensor(name, coordinator, description)) - else: - sensors.append(GiosSensor(name, coordinator, description)) + sensors.append(GiosSensor(name, coordinator, description)) + async_add_entities(sensors) @@ -174,45 +239,27 @@ class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity): name=name, configuration_url=URL.format(station_id=coordinator.gios.station_id), ) - self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" - self._attrs: dict[str, Any] = { - ATTR_STATION: self.coordinator.gios.station_name, - } + if description.subkey: + self._attr_unique_id = ( + f"{coordinator.gios.station_id}-{description.key}-{description.subkey}" + ) + else: + self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" self.entity_description = description - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - self._attrs[ATTR_NAME] = getattr( - self.coordinator.data, self.entity_description.key - ).name - self._attrs[ATTR_INDEX] = getattr( - self.coordinator.data, self.entity_description.key - ).index - return self._attrs - @property def native_value(self) -> StateType: """Return the state.""" - state = getattr(self.coordinator.data, self.entity_description.key).value - assert self.entity_description.value is not None - return cast(StateType, self.entity_description.value(state)) - - -class GiosAqiSensor(GiosSensor): - """Define an GIOS AQI sensor.""" - - @property - def native_value(self) -> StateType: - """Return the state.""" - return cast( - StateType, getattr(self.coordinator.data, self.entity_description.key).value - ) + return self.entity_description.value(self.coordinator.data) @property def available(self) -> bool: """Return if entity is available.""" available = super().available - return available and bool( - getattr(self.coordinator.data, self.entity_description.key) - ) + sensor_data = getattr(self.coordinator.data, self.entity_description.key) + + # Sometimes the API returns sensor data without indexes + if self.entity_description.subkey: + return available and bool(sensor_data.index) + + return available and bool(sensor_data) diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index a76bd3f612..53e7dd78a8 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -34,6 +34,56 @@ "good": "Good", "very_good": "Very good" } + }, + "no2_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "o3_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "pm10_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "pm25_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "so2_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } } } } diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index c5b19502a0..48f0e23840 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -5,12 +5,7 @@ from unittest.mock import patch from gios import ApiError -from homeassistant.components.gios.const import ( - ATTR_INDEX, - ATTR_STATION, - ATTRIBUTION, - DOMAIN, -) +from homeassistant.components.gios.const import ATTRIBUTION, DOMAIN from homeassistant.components.sensor import ( ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -42,16 +37,14 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_c6h6") assert state - assert state.state == "0" + assert state.state == "0.23789" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - assert state.attributes.get(ATTR_INDEX) == "very_good" entry = registry.async_get("sensor.home_c6h6") assert entry @@ -59,16 +52,14 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_co") assert state - assert state.state == "252" + assert state.state == "251.874" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_co") assert entry @@ -76,94 +67,173 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_no2") assert state - assert state.state == "7" + assert state.state == "7.13411" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.NITROGEN_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_no2") assert entry assert entry.unique_id == "123-no2" + state = hass.states.get("sensor.home_no2_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_no2_index") + assert entry + assert entry.unique_id == "123-no2-index" + state = hass.states.get("sensor.home_o3") assert state - assert state.state == "96" + assert state.state == "95.7768" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.OZONE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_o3") assert entry assert entry.unique_id == "123-o3" + state = hass.states.get("sensor.home_o3_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_o3_index") + assert entry + assert entry.unique_id == "123-o3-index" + state = hass.states.get("sensor.home_pm10") assert state - assert state.state == "17" + assert state.state == "16.8344" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_pm10") assert entry assert entry.unique_id == "123-pm10" + state = hass.states.get("sensor.home_pm10_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_pm10_index") + assert entry + assert entry.unique_id == "123-pm10-index" + state = hass.states.get("sensor.home_pm2_5") assert state assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-pm25" + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_pm2_5_index") + assert entry + assert entry.unique_id == "123-pm25-index" + state = hass.states.get("sensor.home_so2") assert state - assert state.state == "4" + assert state.state == "4.35478" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SULPHUR_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "very_good" entry = registry.async_get("sensor.home_so2") assert entry assert entry.unique_id == "123-so2" + state = hass.states.get("sensor.home_so2_index") + assert state + assert state.state == "very_good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_so2_index") + assert entry + assert entry.unique_id == "123-so2-index" + state = hass.states.get("sensor.home_aqi") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.attributes.get(ATTR_OPTIONS) == [ @@ -182,13 +252,23 @@ async def test_sensor(hass: HomeAssistant) -> None: async def test_availability(hass: HomeAssistant) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" + indexes = json.loads(load_fixture("gios/indexes.json")) + sensors = json.loads(load_fixture("gios/sensors.json")) + await init_integration(hass) state = hass.states.get("sensor.home_pm2_5") assert state - assert state.state != STATE_UNAVAILABLE assert state.state == "4" + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == "good" + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == "good" + future = utcnow() + timedelta(minutes=60) with patch( "homeassistant.components.gios.Gios._get_all_sensors", @@ -201,10 +281,18 @@ async def test_availability(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNAVAILABLE + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == STATE_UNAVAILABLE + future = utcnow() + timedelta(minutes=120) with patch( "homeassistant.components.gios.Gios._get_all_sensors", - return_value=json.loads(load_fixture("gios/sensors.json")), + return_value=sensors, ), patch( "homeassistant.components.gios.Gios._get_indexes", return_value={}, @@ -214,161 +302,69 @@ async def test_availability(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_pm2_5") assert state - assert state.state != STATE_UNAVAILABLE assert state.state == "4" + # Indexes are empty so the state should be unavailable state = hass.states.get("sensor.home_aqi") assert state assert state.state == STATE_UNAVAILABLE + # Indexes are empty so the state should be unavailable + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == STATE_UNAVAILABLE + + future = utcnow() + timedelta(minutes=180) + with patch( + "homeassistant.components.gios.Gios._get_all_sensors", return_value=sensors + ), patch( + "homeassistant.components.gios.Gios._get_indexes", + return_value=indexes, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state == "4" + + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == "good" + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == "good" + async def test_invalid_indexes(hass: HomeAssistant) -> None: """Test states of the sensor when API returns invalid indexes.""" await init_integration(hass, invalid_indexes=True) - registry = er.async_get(hass) - state = hass.states.get("sensor.home_c6h6") + state = hass.states.get("sensor.home_no2_index") assert state - assert state.state == "0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_c6h6") - assert entry - assert entry.unique_id == "123-c6h6" - - state = hass.states.get("sensor.home_co") + state = hass.states.get("sensor.home_o3_index") assert state - assert state.state == "252" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_co") - assert entry - assert entry.unique_id == "123-co" - - state = hass.states.get("sensor.home_no2") + state = hass.states.get("sensor.home_pm10_index") assert state - assert state.state == "7" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_no2") - assert entry - assert entry.unique_id == "123-no2" - - state = hass.states.get("sensor.home_o3") + state = hass.states.get("sensor.home_pm2_5_index") assert state - assert state.state == "96" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_o3") - assert entry - assert entry.unique_id == "123-o3" - - state = hass.states.get("sensor.home_pm10") + state = hass.states.get("sensor.home_so2_index") assert state - assert state.state == "17" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None - - entry = registry.async_get("sensor.home_pm10") - assert entry - assert entry.unique_id == "123-pm10" - - state = hass.states.get("sensor.home_pm2_5") - assert state - assert state.state == "4" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None - - entry = registry.async_get("sensor.home_pm2_5") - assert entry - assert entry.unique_id == "123-pm25" - - state = hass.states.get("sensor.home_so2") - assert state - assert state.state == "4" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None - - entry = registry.async_get("sensor.home_so2") - assert entry - assert entry.unique_id == "123-so2" + assert state.state == STATE_UNAVAILABLE state = hass.states.get("sensor.home_aqi") assert state is None -async def test_aqi_sensor_availability(hass: HomeAssistant) -> None: - """Ensure that we mark the AQI sensor unavailable correctly when indexes are invalid.""" - await init_integration(hass) - - state = hass.states.get("sensor.home_aqi") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "good" - - future = utcnow() + timedelta(minutes=60) - with patch( - "homeassistant.components.gios.Gios._get_all_sensors", - return_value=json.loads(load_fixture("gios/sensors.json")), - ), patch( - "homeassistant.components.gios.Gios._get_indexes", - return_value={}, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("sensor.home_aqi") - assert state - assert state.state == STATE_UNAVAILABLE - - async def test_unique_id_migration(hass: HomeAssistant) -> None: """Test states of the unique_id migration.""" registry = er.async_get(hass) From c0387a655c888175dd09225ade78f0b13db98f3d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Mar 2023 08:39:34 +0200 Subject: [PATCH 0215/1165] Turn AVM FRITZ!Box Tools binary sensors into coordinator entities (#89955) make binary sensors coordinator entities --- .../components/fritz/binary_sensor.py | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 918a114fdf..228b7d5935 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -15,14 +15,21 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity +from .common import ( + AvmWrapper, + ConnectionInfo, + FritzBoxBaseCoordinatorEntity, + FritzEntityDescription, +) from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @dataclass -class FritzBinarySensorEntityDescription(BinarySensorEntityDescription): +class FritzBinarySensorEntityDescription( + BinarySensorEntityDescription, FritzEntityDescription +): """Describes Fritz sensor entity.""" is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled @@ -34,12 +41,14 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = ( name="Connection", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status, _: bool(status.is_connected), ), FritzBinarySensorEntityDescription( key="is_linked", name="Link", device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status, _: bool(status.is_linked), ), ) @@ -62,25 +71,16 @@ async def async_setup_entry( async_add_entities(entities, True) -class FritzBoxBinarySensor(FritzBoxBaseEntity, BinarySensorEntity): +class FritzBoxBinarySensor(FritzBoxBaseCoordinatorEntity, BinarySensorEntity): """Define FRITZ!Box connectivity class.""" - def __init__( - self, - avm_wrapper: AvmWrapper, - device_friendly_name: str, - description: BinarySensorEntityDescription, - ) -> None: - """Init FRITZ!Box connectivity class.""" - self.entity_description = description - self._attr_name = f"{device_friendly_name} {description.name}" - self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" - super().__init__(avm_wrapper, device_friendly_name) + entity_description: FritzBinarySensorEntityDescription - def update(self) -> None: - """Update data.""" - _LOGGER.debug("Updating FRITZ!Box binary sensors") - if self.entity_description.key == "is_connected": - self._attr_is_on = bool(self._avm_wrapper.fritz_status.is_connected) - elif self.entity_description.key == "is_linked": - self._attr_is_on = bool(self._avm_wrapper.fritz_status.is_linked) + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if isinstance( + state := self.coordinator.data.get(self.entity_description.key), bool + ): + return state + return None From b399e5c8b7dad57beb2e4faf8676e61dd19ab1fe Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 28 Mar 2023 02:45:10 -0400 Subject: [PATCH 0216/1165] Handle uncaught exceptions during update in Aladdin_connect (#89889) * Handle uncaught errors during update * Remove unnecssary patch * Update tests/components/aladdin_connect/test_cover.py Co-authored-by: Franck Nijhof * Update tests/components/aladdin_connect/test_cover.py Co-authored-by: Franck Nijhof * Remove unasserted statement * Blocking is True - one more --------- Co-authored-by: Franck Nijhof --- .../components/aladdin_connect/cover.py | 17 ++- .../components/aladdin_connect/test_cover.py | 111 ++++++++++-------- 2 files changed, 74 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 5837920560..2cf526e562 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from typing import Any -from AIOAladdinConnect import AladdinConnectClient +from AIOAladdinConnect import AladdinConnectClient, session_manager from homeassistant.components.cover import CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry @@ -46,7 +46,7 @@ class AladdinDevice(CoverEntity): ) -> None: """Initialize the Aladdin Connect cover.""" self._acc = acc - + self._entry_id = entry.entry_id self._device_id = device["device_id"] self._number = device["door_number"] self._name = device["name"] @@ -85,7 +85,18 @@ class AladdinDevice(CoverEntity): async def async_update(self) -> None: """Update status of cover.""" - await self._acc.get_doors(self._serial) + try: + await self._acc.get_doors(self._serial) + self._attr_available = True + + except session_manager.ConnectionError: + self._attr_available = False + + except session_manager.InvalidPasswordError: + self._attr_available = False + await self.hass.async_create_task( + self.hass.config_entries.async_reload(self._entry_id) + ) @property def is_closed(self) -> bool | None: diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index e63b50607c..eb617b959a 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -1,6 +1,8 @@ """Test the Aladdin Connect Cover.""" from unittest.mock import AsyncMock, MagicMock, patch +from AIOAladdinConnect import session_manager + from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL from homeassistant.components.cover import DOMAIN as COVER_DOMAIN @@ -13,6 +15,7 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -97,8 +100,10 @@ async def test_cover_operation( assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_OPEN) mock_aladdinconnect_api.get_door_status.return_value = STATE_OPEN + with patch( "homeassistant.components.aladdin_connect.AladdinConnectClient", return_value=mock_aladdinconnect_api, @@ -116,27 +121,22 @@ async def test_cover_operation( {ATTR_ENTITY_ID: "cover.home"}, blocking=True, ) - await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_OPEN mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_CLOSED) mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSED - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_CLOSED @@ -145,15 +145,11 @@ async def test_cover_operation( ) mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSING - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_CLOSING mock_aladdinconnect_api.async_get_door_status = AsyncMock( @@ -161,34 +157,47 @@ async def test_cover_operation( ) mock_aladdinconnect_api.get_door_status.return_value = STATE_OPENING - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_OPENING mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=None) mock_aladdinconnect_api.get_door_status.return_value = None - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_UNKNOWN + + mock_aladdinconnect_api.get_doors.side_effect = session_manager.ConnectionError + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + assert hass.states.get("cover.home").state == STATE_UNAVAILABLE + + mock_aladdinconnect_api.get_doors.side_effect = session_manager.InvalidPasswordError + mock_aladdinconnect_api.login.return_value = False + mock_aladdinconnect_api.login.side_effect = session_manager.InvalidPasswordError + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + assert hass.states.get("cover.home").state == STATE_UNAVAILABLE From 8807878529660a0dad04c20f97007182c63e3666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cosmin=20Lu=C8=9B=C4=83?= Date: Tue, 28 Mar 2023 09:46:16 +0300 Subject: [PATCH 0217/1165] Add Mikrotik WifiWave2 (#89711) * Add support for wifiwave2 * Add test for wifiwave2 --- homeassistant/components/mikrotik/const.py | 5 +++ homeassistant/components/mikrotik/hub.py | 7 ++++ tests/components/mikrotik/__init__.py | 32 +++++++++++++++++++ .../mikrotik/test_device_tracker.py | 23 +++++++++++++ 4 files changed, 67 insertions(+) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 911d348365..4354b9b06b 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -24,8 +24,11 @@ ARP: Final = "arp" CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" WIRELESS: Final = "wireless" +WIFIWAVE2: Final = "wifiwave2" IS_WIRELESS: Final = "is_wireless" IS_CAPSMAN: Final = "is_capsman" +IS_WIFIWAVE2: Final = "is_wifiwave2" + MIKROTIK_SERVICES: Final = { ARP: "/ip/arp/getall", @@ -34,8 +37,10 @@ MIKROTIK_SERVICES: Final = { IDENTITY: "/system/identity/getall", INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", + WIFIWAVE2: "/interface/wifiwave2/registration-table/print", IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", + IS_WIFIWAVE2: "/interface/wifiwave2/print", } diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 26a5894862..9e0a610c77 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -31,9 +31,11 @@ from .const import ( IDENTITY, INFO, IS_CAPSMAN, + IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + WIFIWAVE2, WIRELESS, ) from .device import Device @@ -57,6 +59,7 @@ class MikrotikData: self.devices: dict[str, Device] = {} self.support_capsman: bool = False self.support_wireless: bool = False + self.support_wifiwave2: bool = False self.hostname: str = "" self.model: str = "" self.firmware: str = "" @@ -97,6 +100,7 @@ class MikrotikData: self.serial_number = self.get_info(ATTR_SERIAL_NUMBER) self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) + self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -121,6 +125,9 @@ class MikrotikData: elif self.support_wireless: _LOGGER.debug("Hub supports wireless Interface") device_list = wireless_devices = self.get_list_from_interface(WIRELESS) + elif self.support_wifiwave2: + _LOGGER.debug("Hub supports wifiwave2 Interface") + device_list = wireless_devices = self.get_list_from_interface(WIFIWAVE2) if not device_list or self.force_dhcp: device_list = self.all_devices diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index b7f79f8ea5..158f86fe45 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -62,6 +62,14 @@ DEVICE_3_DHCP_NUMERIC_NAME = { "host-name": 123, "comment": "Mobile", } +DEVICE_4_DHCP = { + ".id": "*F7", + "address": "0.0.0.4", + "mac-address": "00:00:00:00:00:04", + "active-address": "0.0.0.4", + "host-name": "Device_4", + "comment": "Wifiwave2 device", +} DEVICE_1_WIRELESS = { ".id": "*264", "interface": "wlan1", @@ -109,9 +117,27 @@ DEVICE_3_WIRELESS = { "mac-address": "00:00:00:00:00:03", "last-ip": "0.0.0.3", } + +DEVICE_4_WIFIWAVE2 = { + ".id": "*F7", + "interface": "wifi1", + "ssid": "test-ssid", + "mac-address": "00:00:00:00:00:04", + "uptime": "2d15h28m27s", + "signal": -47, + "tx-rate": 54000000, + "rx-rate": 54000000, + "packets": "17748,18516", + "bytes": "1851474,2037295", + "tx-bits-per-second": 0, + "rx-bits-per-second": 0, + "authorized": True, +} + DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] WIRELESS_DATA = [DEVICE_1_WIRELESS] +WIFIWAVE2_DATA = [DEVICE_4_WIFIWAVE2] ARP_DATA = [ { @@ -144,16 +170,22 @@ ARP_DATA = [ async def setup_mikrotik_entry(hass: HomeAssistant, **kwargs: Any) -> None: """Set up Mikrotik integration successfully.""" support_wireless: bool = kwargs.get("support_wireless", True) + support_wifiwave2: bool = kwargs.get("support_wifiwave2", False) dhcp_data: list[dict[str, Any]] = kwargs.get("dhcp_data", DHCP_DATA) wireless_data: list[dict[str, Any]] = kwargs.get("wireless_data", WIRELESS_DATA) + wifiwave2_data: list[dict[str, Any]] = kwargs.get("wifiwave2_data", WIFIWAVE2_DATA) def mock_command(self, cmd: str, params: dict[str, Any] | None = None) -> Any: if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: return support_wireless + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIFIWAVE2]: + return support_wifiwave2 if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: return dhcp_data if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: return wireless_data + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIFIWAVE2]: + return wifiwave2_data if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: return ARP_DATA return {} diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index bd921320d7..323c958eb2 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -18,6 +18,8 @@ from . import ( DEVICE_2_WIRELESS, DEVICE_3_DHCP_NUMERIC_NAME, DEVICE_3_WIRELESS, + DEVICE_4_DHCP, + DEVICE_4_WIFIWAVE2, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, @@ -39,6 +41,7 @@ def mock_device_registry_devices(hass: HomeAssistant) -> None: "00:00:00:00:00:01", "00:00:00:00:00:02", "00:00:00:00:00:03", + "00:00:00:00:00:04", ) ): dev_reg.async_get_or_create( @@ -184,6 +187,26 @@ async def test_device_trackers_numerical_name( assert device_3.attributes["host_name"] == "123" +async def test_hub_wifiwave2(hass: HomeAssistant, mock_device_registry_devices) -> None: + """Test device_trackers created when hub supports wifiwave2.""" + + await setup_mikrotik_entry( + hass, + dhcp_data=[DEVICE_4_DHCP], + wifiwave2_data=[DEVICE_4_WIFIWAVE2], + support_wireless=False, + support_wifiwave2=True, + ) + + device_4 = hass.states.get("device_tracker.device_4") + assert device_4 + assert device_4.state == "home" + assert device_4.attributes["friendly_name"] == "Device_4" + assert device_4.attributes["ip"] == "0.0.0.4" + assert device_4.attributes["mac"] == "00:00:00:00:00:04" + assert device_4.attributes["host_name"] == "Device_4" + + async def test_restoring_devices(hass: HomeAssistant) -> None: """Test restoring existing device_tracker entities if not detected on startup.""" config_entry = MockConfigEntry( From db6f0827aa9b9f9c31dd49f8c2ff552a8c9f7093 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Tue, 28 Mar 2023 14:47:45 +0800 Subject: [PATCH 0218/1165] Allow reloading iZone config entry (#89572) * Allow reloading of iZone config entries --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/izone/__init__.py | 28 +++++++++++++-------- homeassistant/components/izone/climate.py | 4 ++- homeassistant/components/izone/discovery.py | 17 +++++++------ 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 3f2565bd8f..fd8d27ac42 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -3,7 +3,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EXCLUDE, Platform +from homeassistant.const import CONF_EXCLUDE, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -29,29 +29,35 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the iZone component config.""" - if not (conf := config.get(IZONE)): - return True - hass.data[DATA_CONFIG] = conf + # Check for manually added config, this may exclude some devices + if conf := config.get(IZONE): + hass.data[DATA_CONFIG] = conf - # Explicitly added in the config file, create a config entry. - hass.async_create_task( - hass.config_entries.flow.async_init( - IZONE, context={"source": config_entries.SOURCE_IMPORT} + # Explicitly added in the config file, create a config entry. + hass.async_create_task( + hass.config_entries.flow.async_init( + IZONE, context={"source": config_entries.SOURCE_IMPORT} + ) ) - ) + + # Start the discovery service + await async_start_discovery_service(hass) + + async def shutdown_event(event): + await async_stop_discovery_service(hass) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - await async_start_discovery_service(hass) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the config entry and stop discovery process.""" - await async_stop_discovery_service(hass) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 3e19afcca2..e5a45dbc5e 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -95,7 +95,9 @@ async def async_setup_entry( init_controller(controller) # connect to register any further components - async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller) + config.async_on_unload( + async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller) + ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index eb6e7d4a19..a170ed30a7 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -1,7 +1,8 @@ """Internal discovery service for iZone AC.""" +import logging + import pizone -from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -15,15 +16,17 @@ from .const import ( DISPATCH_ZONE_UPDATE, ) +_LOGGER = logging.getLogger(__name__) + class DiscoveryService(pizone.Listener): """Discovery data and interfacing with pizone library.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialise discovery service.""" super().__init__() self.hass = hass - self.pi_disco = None + self.pi_disco: pizone.DiscoveryService | None = None # Listener interface def controller_discovered(self, ctrl: pizone.Controller) -> None: @@ -52,6 +55,7 @@ async def async_start_discovery_service(hass: HomeAssistant): if disco := hass.data.get(DATA_DISCOVERY_SERVICE): # Already started return disco + _LOGGER.debug("Starting iZone Discovery Service") # discovery local services disco = DiscoveryService(hass) @@ -62,11 +66,6 @@ async def async_start_discovery_service(hass: HomeAssistant): disco.pi_disco = pizone.discovery(disco, session=session) await disco.pi_disco.start_discovery() - async def shutdown_event(event): - await async_stop_discovery_service(hass) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event) - return disco @@ -77,3 +76,5 @@ async def async_stop_discovery_service(hass: HomeAssistant): await disco.pi_disco.close() del hass.data[DATA_DISCOVERY_SERVICE] + + _LOGGER.debug("Stopped iZone Discovery Service") From 38f3b9f165f3ccd0366ee9a4ba59499b9cb43c44 Mon Sep 17 00:00:00 2001 From: Mark Adkins Date: Tue, 28 Mar 2023 02:48:32 -0400 Subject: [PATCH 0219/1165] Add SharkIQ EU region support (#89349) * SharkIQ Dep & Codeowner Update * Update code owners * Add EU Region Support * Update Config Flow Tests * Standardize Region Comparison Strings * Add Translation Support to Region Selector * Fix Validation Tests --- homeassistant/components/sharkiq/__init__.py | 5 +- .../components/sharkiq/config_flow.py | 49 ++++++++++++++++--- homeassistant/components/sharkiq/const.py | 5 ++ homeassistant/components/sharkiq/strings.json | 19 ++++++- tests/components/sharkiq/const.py | 9 +++- tests/components/sharkiq/test_config_flow.py | 11 +++-- 6 files changed, 80 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index 0c4f7bb0bf..738dd595a5 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -13,11 +13,11 @@ from sharkiq import ( from homeassistant import exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import API_TIMEOUT, DOMAIN, LOGGER, PLATFORMS +from .const import API_TIMEOUT, DOMAIN, LOGGER, PLATFORMS, SHARKIQ_REGION_EUROPE from .update_coordinator import SharkIqUpdateCoordinator @@ -47,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b username=config_entry.data[CONF_USERNAME], password=config_entry.data[CONF_PASSWORD], websession=async_get_clientsession(hass), + europe=(config_entry.data[CONF_REGION] == SHARKIQ_REGION_EUROPE), ) try: diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index b0aae5259d..57de36ce41 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -11,14 +11,31 @@ from sharkiq import SharkIqAuthError, get_ayla_api import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, LOGGER +from .const import ( + DOMAIN, + LOGGER, + SHARKIQ_REGION_DEFAULT, + SHARKIQ_REGION_EUROPE, + SHARKIQ_REGION_OPTIONS, +) SHARKIQ_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required( + CONF_REGION, default=SHARKIQ_REGION_DEFAULT + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=SHARKIQ_REGION_OPTIONS, translation_key="region" + ), + ), + } ) @@ -30,16 +47,29 @@ async def _validate_input( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], websession=async_get_clientsession(hass), + europe=(data[CONF_REGION] == SHARKIQ_REGION_EUROPE), ) try: async with async_timeout.timeout(10): LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() - except (asyncio.TimeoutError, aiohttp.ClientError) as errors: - raise CannotConnect from errors + except (asyncio.TimeoutError, aiohttp.ClientError, TypeError) as error: + LOGGER.error(error) + raise CannotConnect( + "Unable to connect to SharkIQ services. Check your region settings." + ) from error except SharkIqAuthError as error: - raise InvalidAuth from error + LOGGER.error(error) + raise InvalidAuth( + "Username or password incorrect. Please check your credentials." + ) from error + except Exception as error: + LOGGER.exception("Unexpected exception") + LOGGER.error(error) + raise UnknownAuth( + "An unknown error occurred. Check your region settings and open an issue on Github if the issue persists." + ) from error # Return info that you want to store in the config entry. return {"title": data[CONF_USERNAME]} @@ -64,8 +94,7 @@ class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") + except UnknownAuth: # pylint: disable=broad-except errors["base"] = "unknown" return info, errors @@ -114,3 +143,7 @@ class CannotConnect(exceptions.HomeAssistantError): class InvalidAuth(exceptions.HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class UnknownAuth(exceptions.HomeAssistantError): + """Error to indicate there is an uncaught auth error.""" diff --git a/homeassistant/components/sharkiq/const.py b/homeassistant/components/sharkiq/const.py index fb683bb525..b12a86dc24 100644 --- a/homeassistant/components/sharkiq/const.py +++ b/homeassistant/components/sharkiq/const.py @@ -11,3 +11,8 @@ PLATFORMS = [Platform.VACUUM] DOMAIN = "sharkiq" SHARK = "Shark" UPDATE_INTERVAL = timedelta(seconds=30) + +SHARKIQ_REGION_EUROPE = "europe" +SHARKIQ_REGION_ELSEWHERE = "elsewhere" +SHARKIQ_REGION_DEFAULT = SHARKIQ_REGION_ELSEWHERE +SHARKIQ_REGION_OPTIONS = [SHARKIQ_REGION_EUROPE, SHARKIQ_REGION_ELSEWHERE] diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index bc920ac7c7..23f949be4c 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -1,16 +1,23 @@ { "config": { + "flow_title": "Add Shark IQ Account", "step": { "user": { + "description": "Sign into your Shark Clean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "region": "Region" + }, + "data_description": { + "region": "Shark IQ uses different services in the EU. Select your region to connect to the correct service for your account." } }, "reauth": { "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "region": "Region" } } }, @@ -25,5 +32,13 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "selector": { + "region": { + "options": { + "europe": "Europe", + "elsewhere": "Everywhere Else" + } + } } } diff --git a/tests/components/sharkiq/const.py b/tests/components/sharkiq/const.py index 305d12ddfa..8ec7d424ff 100644 --- a/tests/components/sharkiq/const.py +++ b/tests/components/sharkiq/const.py @@ -1,6 +1,6 @@ """Constants used in shark iq tests.""" -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME # Dummy device dict of the form returned by AylaApi.list_devices() SHARK_DEVICE_DICT = { @@ -69,6 +69,11 @@ SHARK_PROPERTIES_DICT = { TEST_USERNAME = "test-username" TEST_PASSWORD = "test-password" +TEST_REGION = "elsewhere" UNIQUE_ID = "foo@bar.com" -CONFIG = {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD} +CONFIG = { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_REGION: TEST_REGION, +} ENTRY_ID = "0123456789abcdef0123456789abcdef" diff --git a/tests/components/sharkiq/test_config_flow.py b/tests/components/sharkiq/test_config_flow.py index f611d8e6d8..c7a0603f86 100644 --- a/tests/components/sharkiq/test_config_flow.py +++ b/tests/components/sharkiq/test_config_flow.py @@ -3,13 +3,13 @@ from unittest.mock import patch import aiohttp import pytest -from sharkiq import AylaApi, SharkIqAuthError +from sharkiq import AylaApi, SharkIqAuthError, SharkIqError from homeassistant import config_entries from homeassistant.components.sharkiq.const import DOMAIN from homeassistant.core import HomeAssistant -from .const import CONFIG, TEST_PASSWORD, TEST_USERNAME, UNIQUE_ID +from .const import CONFIG, TEST_PASSWORD, TEST_REGION, TEST_USERNAME, UNIQUE_ID from tests.common import MockConfigEntry @@ -37,6 +37,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "username": TEST_USERNAME, "password": TEST_PASSWORD, + "region": TEST_REGION, } await hass.async_block_till_done() mock_setup_entry.assert_called_once() @@ -47,7 +48,8 @@ async def test_form(hass: HomeAssistant) -> None: [ (SharkIqAuthError, "invalid_auth"), (aiohttp.ClientError, "cannot_connect"), - (TypeError, "unknown"), + (TypeError, "cannot_connect"), + (SharkIqError, "unknown"), ], ) async def test_form_error(hass: HomeAssistant, exc: Exception, base_error: str) -> None: @@ -87,7 +89,8 @@ async def test_reauth_success(hass: HomeAssistant) -> None: [ (SharkIqAuthError, "form", "errors", "invalid_auth"), (aiohttp.ClientError, "abort", "reason", "cannot_connect"), - (TypeError, "abort", "reason", "unknown"), + (TypeError, "abort", "reason", "cannot_connect"), + (SharkIqError, "abort", "reason", "unknown"), ], ) async def test_reauth( From bfb5daa31c23afd788a9430dfec040bf96e4b90d Mon Sep 17 00:00:00 2001 From: gjong Date: Tue, 28 Mar 2023 08:49:31 +0200 Subject: [PATCH 0220/1165] Add phase information to YouLess (#89255) --- homeassistant/components/youless/sensor.py | 99 +++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index b9120f433d..057533081e 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -10,7 +10,14 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, UnitOfEnergy, UnitOfPower, UnitOfVolume +from homeassistant.const import ( + CONF_DEVICE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -47,6 +54,15 @@ async def async_setup_entry( DeliveryMeterSensor(coordinator, device, "high"), ExtraMeterSensor(coordinator, device, "total"), ExtraMeterPowerSensor(coordinator, device, "usage"), + PhasePowerSensor(coordinator, device, 1), + PhaseVoltageSensor(coordinator, device, 1), + PhaseCurrentSensor(coordinator, device, 1), + PhasePowerSensor(coordinator, device, 2), + PhaseVoltageSensor(coordinator, device, 2), + PhaseCurrentSensor(coordinator, device, 2), + PhasePowerSensor(coordinator, device, 3), + PhaseVoltageSensor(coordinator, device, 3), + PhaseCurrentSensor(coordinator, device, 3), ] ) @@ -193,6 +209,87 @@ class EnergyMeterSensor(YoulessBaseSensor): return getattr(self.coordinator.data.power_meter, f"_{self._type}", None) +class PhasePowerSensor(YoulessBaseSensor): + """The current power usage of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the power phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_power" + ) + self._attr_name = f"Phase {phase} power" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.power + + +class PhaseVoltageSensor(YoulessBaseSensor): + """The current voltage of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the voltage phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_voltage" + ) + self._attr_name = f"Phase {phase} voltage" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator for phase voltage.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.voltage + + +class PhaseCurrentSensor(YoulessBaseSensor): + """The current current of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_device_class = SensorDeviceClass.CURRENT + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the current phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_current" + ) + self._attr_name = f"Phase {phase} current" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator for phase current.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.current + + class ExtraMeterSensor(YoulessBaseSensor): """The Youless extra meter value sensor (s0).""" From 586471b5a9eaffa99b94c577a07bc7a0add8d046 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 09:11:13 +0200 Subject: [PATCH 0221/1165] Improve threshold binary sensor (#88978) Improve threshold sensor --- .../components/threshold/binary_sensor.py | 58 ++++++++++------ .../threshold/test_binary_sensor.py | 68 +++++++++---------- 2 files changed, 71 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 0badf7eb41..538655ec0c 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -114,6 +114,15 @@ async def async_setup_platform( ) +def _threshold_type(lower: float | None, upper: float | None) -> str: + """Return the type of threshold this sensor represents.""" + if lower is not None and upper is not None: + return TYPE_RANGE + if lower is not None: + return TYPE_LOWER + return TYPE_UPPER + + class ThresholdSensor(BinarySensorEntity): """Representation of a Threshold sensor.""" @@ -134,8 +143,11 @@ class ThresholdSensor(BinarySensorEntity): self._attr_unique_id = unique_id self._entity_id = entity_id self._name = name - self._threshold_lower = lower - self._threshold_upper = upper + if lower is not None: + self._threshold_lower = lower + if upper is not None: + self._threshold_upper = upper + self.threshold_type = _threshold_type(lower, upper) self._hysteresis: float = hysteresis self._device_class = device_class self._state_position = POSITION_UNKNOWN @@ -187,26 +199,17 @@ class ThresholdSensor(BinarySensorEntity): """Return the sensor class of the sensor.""" return self._device_class - @property - def threshold_type(self) -> str: - """Return the type of threshold this sensor represents.""" - if self._threshold_lower is not None and self._threshold_upper is not None: - return TYPE_RANGE - if self._threshold_lower is not None: - return TYPE_LOWER - return TYPE_UPPER - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, ATTR_HYSTERESIS: self._hysteresis, - ATTR_LOWER: self._threshold_lower, + ATTR_LOWER: getattr(self, "_threshold_lower", None), ATTR_POSITION: self._state_position, ATTR_SENSOR_VALUE: self.sensor_value, ATTR_TYPE: self.threshold_type, - ATTR_UPPER: self._threshold_upper, + ATTR_UPPER: getattr(self, "_threshold_upper", None), } @callback @@ -223,30 +226,42 @@ class ThresholdSensor(BinarySensorEntity): if self.sensor_value is None: self._state_position = POSITION_UNKNOWN - self._state = False + self._state = None return - if self.threshold_type == TYPE_LOWER and self._threshold_lower is not None: + if self.threshold_type == TYPE_LOWER: + if self._state is None: + self._state = False + self._state_position = POSITION_ABOVE + if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = True elif above(self.sensor_value, self._threshold_lower): self._state_position = POSITION_ABOVE self._state = False + return + + if self.threshold_type == TYPE_UPPER: + assert self._threshold_upper is not None + + if self._state is None: + self._state = False + self._state_position = POSITION_BELOW - if self.threshold_type == TYPE_UPPER and self._threshold_upper is not None: if above(self.sensor_value, self._threshold_upper): self._state_position = POSITION_ABOVE self._state = True elif below(self.sensor_value, self._threshold_upper): self._state_position = POSITION_BELOW self._state = False + return + + if self.threshold_type == TYPE_RANGE: + if self._state is None: + self._state = True + self._state_position = POSITION_IN_RANGE - if ( - self.threshold_type == TYPE_RANGE - and self._threshold_lower is not None - and self._threshold_upper is not None - ): if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = False @@ -258,3 +273,4 @@ class ThresholdSensor(BinarySensorEntity): ): self._state_position = POSITION_IN_RANGE self._state = True + return diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index eed3a8a40e..9e11195d87 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -29,8 +29,8 @@ async def test_sensor_upper(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "below" + assert state.state == "off" hass.states.async_set( "sensor.test_monitored", @@ -63,12 +63,12 @@ async def test_sensor_upper(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" + assert state.attributes["position"] == "below" assert state.state == "off" @@ -89,8 +89,8 @@ async def test_sensor_lower(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "above" + assert state.state == "off" hass.states.async_set("sensor.test_monitored", 16) await hass.async_block_till_done() @@ -117,12 +117,12 @@ async def test_sensor_lower(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" + assert state.attributes["position"] == "above" assert state.state == "off" @@ -144,15 +144,15 @@ async def test_sensor_upper_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 17.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "below" + assert state.state == "off" # Set the monitored sensor's state to the threshold - hysteresis hass.states.async_set("sensor.test_monitored", 12.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "below" + assert state.state == "off" hass.states.async_set("sensor.test_monitored", 20) await hass.async_block_till_done() @@ -192,7 +192,7 @@ async def test_sensor_upper_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() @@ -219,15 +219,15 @@ async def test_sensor_lower_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 17.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "above" + assert state.state == "off" # Set the monitored sensor's state to the threshold - hysteresis hass.states.async_set("sensor.test_monitored", 12.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "above" + assert state.state == "off" hass.states.async_set("sensor.test_monitored", 20) await hass.async_block_till_done() @@ -267,7 +267,7 @@ async def test_sensor_lower_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() @@ -294,15 +294,15 @@ async def test_sensor_in_range_no_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 10) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the upper threshold hass.states.async_set("sensor.test_monitored", 20) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" hass.states.async_set( "sensor.test_monitored", @@ -336,7 +336,7 @@ async def test_sensor_in_range_no_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 21) await hass.async_block_till_done() @@ -364,29 +364,29 @@ async def test_sensor_in_range_with_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 8) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the lower threshold + hysteresis hass.states.async_set("sensor.test_monitored", 12) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the upper threshold + hysteresis hass.states.async_set("sensor.test_monitored", 22) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the upper threshold - hysteresis hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" hass.states.async_set( "sensor.test_monitored", @@ -460,7 +460,7 @@ async def test_sensor_in_range_with_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 17) await hass.async_block_till_done() @@ -507,13 +507,13 @@ async def test_sensor_in_range_unknown_state( await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" assert "State is not numerical" not in caplog.text From dc05272120af9560b120cf41567c3f5658724c8d Mon Sep 17 00:00:00 2001 From: stickpin <630000+stickpin@users.noreply.github.com> Date: Tue, 28 Mar 2023 09:14:19 +0200 Subject: [PATCH 0222/1165] Display only supported Home Connect appliance programs (#88801) Show only supported device programs --- homeassistant/components/home_connect/api.py | 115 ++----------------- 1 file changed, 7 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index f50ab71155..85d8abd1cb 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -145,11 +145,14 @@ class HomeConnectDevice: class DeviceWithPrograms(HomeConnectDevice): """Device with programs.""" - PROGRAMS: list[dict[str, str]] = [] - def get_programs_available(self): """Get the available programs.""" - return self.PROGRAMS + try: + programs_available = self.appliance.get_programs_available() + except (HomeConnectError, ValueError): + _LOGGER.debug("Unable to fetch available programs. Probably offline") + programs_available = None + return programs_available def get_program_switches(self): """Get a dictionary with info about program switches. @@ -157,7 +160,7 @@ class DeviceWithPrograms(HomeConnectDevice): There will be one switch for each program. """ programs = self.get_programs_available() - return [{ATTR_DEVICE: self, "program_name": p["name"]} for p in programs] + return [{ATTR_DEVICE: self, "program_name": p} for p in programs] def get_program_sensors(self): """Get a dictionary with info about program sensors. @@ -265,27 +268,6 @@ class Dryer( ): """Dryer class.""" - PROGRAMS = [ - {"name": "LaundryCare.Dryer.Program.Cotton"}, - {"name": "LaundryCare.Dryer.Program.Synthetic"}, - {"name": "LaundryCare.Dryer.Program.Mix"}, - {"name": "LaundryCare.Dryer.Program.Blankets"}, - {"name": "LaundryCare.Dryer.Program.BusinessShirts"}, - {"name": "LaundryCare.Dryer.Program.DownFeathers"}, - {"name": "LaundryCare.Dryer.Program.Hygiene"}, - {"name": "LaundryCare.Dryer.Program.Jeans"}, - {"name": "LaundryCare.Dryer.Program.Outdoor"}, - {"name": "LaundryCare.Dryer.Program.SyntheticRefresh"}, - {"name": "LaundryCare.Dryer.Program.Towels"}, - {"name": "LaundryCare.Dryer.Program.Delicates"}, - {"name": "LaundryCare.Dryer.Program.Super40"}, - {"name": "LaundryCare.Dryer.Program.Shirts15"}, - {"name": "LaundryCare.Dryer.Program.Pillow"}, - {"name": "LaundryCare.Dryer.Program.AntiShrink"}, - {"name": "LaundryCare.Dryer.Program.TimeCold"}, - {"name": "LaundryCare.Dryer.Program.TimeWarm"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -311,32 +293,6 @@ class Dishwasher( ): """Dishwasher class.""" - PROGRAMS = [ - {"name": "Dishcare.Dishwasher.Program.Auto1"}, - {"name": "Dishcare.Dishwasher.Program.Auto2"}, - {"name": "Dishcare.Dishwasher.Program.Auto3"}, - {"name": "Dishcare.Dishwasher.Program.Eco50"}, - {"name": "Dishcare.Dishwasher.Program.Quick45"}, - {"name": "Dishcare.Dishwasher.Program.Intensiv70"}, - {"name": "Dishcare.Dishwasher.Program.Normal65"}, - {"name": "Dishcare.Dishwasher.Program.Glas40"}, - {"name": "Dishcare.Dishwasher.Program.GlassCare"}, - {"name": "Dishcare.Dishwasher.Program.PreRinse"}, - {"name": "Dishcare.Dishwasher.Program.NightWash"}, - {"name": "Dishcare.Dishwasher.Program.Quick65"}, - {"name": "Dishcare.Dishwasher.Program.Normal45"}, - {"name": "Dishcare.Dishwasher.Program.Intensiv45"}, - {"name": "Dishcare.Dishwasher.Program.AutoHalfLoad"}, - {"name": "Dishcare.Dishwasher.Program.IntensivPower"}, - {"name": "Dishcare.Dishwasher.Program.MagicDaily"}, - {"name": "Dishcare.Dishwasher.Program.Super60"}, - {"name": "Dishcare.Dishwasher.Program.Kurz60"}, - {"name": "Dishcare.Dishwasher.Program.ExpressSparkle65"}, - {"name": "Dishcare.Dishwasher.Program.MachineCare"}, - {"name": "Dishcare.Dishwasher.Program.SteamFresh"}, - {"name": "Dishcare.Dishwasher.Program.MaximumCleaning"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -361,14 +317,6 @@ class Oven( ): """Oven class.""" - PROGRAMS = [ - {"name": "Cooking.Oven.Program.HeatingMode.PreHeating"}, - {"name": "Cooking.Oven.Program.HeatingMode.HotAir"}, - {"name": "Cooking.Oven.Program.HeatingMode.TopBottomHeating"}, - {"name": "Cooking.Oven.Program.HeatingMode.PizzaSetting"}, - {"name": "Cooking.Oven.Program.Microwave.600Watt"}, - ] - power_off_state = BSH_POWER_STANDBY def get_entity_info(self): @@ -395,30 +343,6 @@ class Washer( ): """Washer class.""" - PROGRAMS = [ - {"name": "LaundryCare.Washer.Program.Cotton"}, - {"name": "LaundryCare.Washer.Program.Cotton.CottonEco"}, - {"name": "LaundryCare.Washer.Program.EasyCare"}, - {"name": "LaundryCare.Washer.Program.Mix"}, - {"name": "LaundryCare.Washer.Program.DelicatesSilk"}, - {"name": "LaundryCare.Washer.Program.Wool"}, - {"name": "LaundryCare.Washer.Program.Sensitive"}, - {"name": "LaundryCare.Washer.Program.Auto30"}, - {"name": "LaundryCare.Washer.Program.Auto40"}, - {"name": "LaundryCare.Washer.Program.Auto60"}, - {"name": "LaundryCare.Washer.Program.Chiffon"}, - {"name": "LaundryCare.Washer.Program.Curtains"}, - {"name": "LaundryCare.Washer.Program.DarkWash"}, - {"name": "LaundryCare.Washer.Program.Dessous"}, - {"name": "LaundryCare.Washer.Program.Monsoon"}, - {"name": "LaundryCare.Washer.Program.Outdoor"}, - {"name": "LaundryCare.Washer.Program.PlushToy"}, - {"name": "LaundryCare.Washer.Program.ShirtsBlouses"}, - {"name": "LaundryCare.Washer.Program.SportFitness"}, - {"name": "LaundryCare.Washer.Program.Towels"}, - {"name": "LaundryCare.Washer.Program.WaterProof"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -437,23 +361,6 @@ class Washer( class CoffeeMaker(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteStart): """Coffee maker class.""" - PROGRAMS = [ - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado"}, - ] - power_off_state = BSH_POWER_STANDBY def get_entity_info(self): @@ -479,12 +386,6 @@ class Hood( ): """Hood class.""" - PROGRAMS = [ - {"name": "Cooking.Common.Program.Hood.Automatic"}, - {"name": "Cooking.Common.Program.Hood.Venting"}, - {"name": "Cooking.Common.Program.Hood.DelayedShutOff"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() @@ -532,8 +433,6 @@ class Freezer(DeviceWithDoor): class Hob(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteControl): """Hob class.""" - PROGRAMS = [{"name": "Cooking.Hob.Program.PowerLevelMode"}] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() From 0666a4750c9b19aafa11da5911088655036ad7a5 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 28 Mar 2023 09:30:42 +0200 Subject: [PATCH 0223/1165] Add re-auth support to philips_js (#88774) * Add re-auth support to philips_js * Adjustments from review * Don't allow duplicate entries for now --- .../components/philips_js/__init__.py | 5 ++- .../components/philips_js/config_flow.py | 42 +++++++++++++++---- .../components/philips_js/strings.json | 3 +- .../components/philips_js/test_config_flow.py | 37 ++++++++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 3145e82a94..55ac33d198 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -19,8 +19,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN @@ -171,4 +172,4 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): except ConnectionFailure: pass except AutenticationFailure as exception: - raise UpdateFailed(str(exception)) from exception + raise ConfigEntryAuthFailed(str(exception)) from exception diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index dab8d4fbe2..9b7e52c211 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Philips TV integration.""" from __future__ import annotations +from collections.abc import Mapping import platform from typing import Any @@ -20,6 +21,18 @@ from homeassistant.data_entry_flow import FlowResult from . import LOGGER from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, CONST_APP_ID, CONST_APP_NAME, DOMAIN +USER_SCHEMA = vol.Schema( + { + vol.Required( + CONF_HOST, + ): str, + vol.Required( + CONF_API_VERSION, + default=1, + ): vol.In([1, 5, 6]), + } +) + async def _validate_input( hass: core.HomeAssistant, host: str, api_version: int @@ -47,9 +60,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._current: dict[str, Any] = {} self._hub: PhilipsTV | None = None self._pair_state: Any = None + self._entry: config_entries.ConfigEntry | None = None async def _async_create_current(self) -> FlowResult: system = self._current[CONF_SYSTEM] + if self._entry: + self.hass.config_entries.async_update_entry( + self._entry, data=self._entry.data | self._current + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=f"{system['name']} ({system['serialnumber']})", data=self._current, @@ -108,6 +131,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._current[CONF_PASSWORD] = password return await self._async_create_current() + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + self._current[CONF_HOST] = entry_data[CONF_HOST] + self._current[CONF_API_VERSION] = entry_data[CONF_API_VERSION] + return await self.async_step_user() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -128,7 +158,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: if serialnumber := hub.system.get("serialnumber"): await self.async_set_unique_id(serialnumber) - self._abort_if_unique_id_configured() + if self._entry is None: + self._abort_if_unique_id_configured() self._current[CONF_SYSTEM] = hub.system self._current[CONF_API_VERSION] = hub.api_version @@ -138,14 +169,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_pair() return await self._async_create_current() - schema = vol.Schema( - { - vol.Required(CONF_HOST, default=self._current.get(CONF_HOST)): str, - vol.Required( - CONF_API_VERSION, default=self._current.get(CONF_API_VERSION, 1) - ): vol.In([1, 5, 6]), - } - ) + schema = self.add_suggested_values_to_schema(USER_SCHEMA, self._current) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) @staticmethod diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index dc25838580..302e1b9acc 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -22,7 +22,8 @@ "invalid_pin": "Invalid PIN" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index 1662a2a3fc..603e278d59 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -12,6 +12,7 @@ from . import ( MOCK_CONFIG, MOCK_CONFIG_PAIRED, MOCK_PASSWORD, + MOCK_SYSTEM, MOCK_SYSTEM_UNPAIRED, MOCK_USERINPUT, MOCK_USERNAME, @@ -56,6 +57,42 @@ async def test_form(hass: HomeAssistant, mock_setup_entry) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_reauth( + hass: HomeAssistant, mock_setup_entry, mock_config_entry, mock_tv +) -> None: + """Test we get the form.""" + + mock_tv.system = MOCK_SYSTEM | {"model": "changed"} + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USERINPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_config_entry.data == MOCK_CONFIG | {"system": mock_tv.system} + assert len(mock_setup_entry.mock_calls) == 2 + + async def test_form_cannot_connect(hass: HomeAssistant, mock_tv) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( From ff135ecdc667d6bd833719368c38637d3fc848e8 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 28 Mar 2023 01:31:36 -0600 Subject: [PATCH 0224/1165] Add a calendar entity to Ridwell (#88108) * Subclass a `DataUpdateCoordinator` for Ridwell * Add a calendar entity to Ridwell * Simpler unique ID * Fix tests * Docstring --- .coveragerc | 1 + homeassistant/components/ridwell/__init__.py | 2 +- homeassistant/components/ridwell/calendar.py | 78 +++++++++++++++++++ .../components/ridwell/coordinator.py | 8 +- .../components/ridwell/diagnostics.py | 6 +- homeassistant/components/ridwell/entity.py | 15 ++-- homeassistant/components/ridwell/sensor.py | 7 +- homeassistant/components/ridwell/switch.py | 16 +++- tests/components/ridwell/conftest.py | 21 +++-- tests/components/ridwell/test_diagnostics.py | 2 +- 10 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/ridwell/calendar.py diff --git a/.coveragerc b/.coveragerc index 520b87b08b..dfc13304b1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -997,6 +997,7 @@ omit = homeassistant/components/rest/notify.py homeassistant/components/rest/switch.py homeassistant/components/ridwell/__init__.py + homeassistant/components/ridwell/calendar.py homeassistant/components/ridwell/coordinator.py homeassistant/components/ridwell/switch.py homeassistant/components/ring/camera.py diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 116528f4ca..1b0a83f1c0 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import entity_registry as er from .const import DOMAIN, LOGGER, SENSOR_TYPE_NEXT_PICKUP from .coordinator import RidwellDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/ridwell/calendar.py b/homeassistant/components/ridwell/calendar.py new file mode 100644 index 0000000000..57919ed1fe --- /dev/null +++ b/homeassistant/components/ridwell/calendar.py @@ -0,0 +1,78 @@ +"""Support for Ridwell calendars.""" +from __future__ import annotations + +import datetime + +from aioridwell.model import RidwellAccount, RidwellPickupEvent + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import RidwellDataUpdateCoordinator +from .entity import RidwellEntity + + +@callback +def async_get_calendar_event_from_pickup_event( + pickup_event: RidwellPickupEvent, +) -> CalendarEvent: + """Get a HASS CalendarEvent from an aioridwell PickupEvent.""" + pickup_type_string = ", ".join( + [ + f"{pickup.name} (quantity: {pickup.quantity})" + for pickup in pickup_event.pickups + ] + ) + return CalendarEvent( + summary=f"Ridwell Pickup ({pickup_event.state.value})", + description=f"Pickup types: {pickup_type_string}", + start=pickup_event.pickup_date, + end=pickup_event.pickup_date + datetime.timedelta(days=1), + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Ridwell calendars based on a config entry.""" + coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + RidwellCalendar(coordinator, account) + for account in coordinator.accounts.values() + ) + + +class RidwellCalendar(RidwellEntity, CalendarEntity): + """Define a Ridwell calendar.""" + + _attr_icon = "mdi:delete-empty" + + def __init__( + self, coordinator: RidwellDataUpdateCoordinator, account: RidwellAccount + ) -> None: + """Initialize the Ridwell entity.""" + super().__init__(coordinator, account) + + self._attr_unique_id = self._account.account_id + self._event: CalendarEvent | None = None + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return async_get_calendar_event_from_pickup_event(self.next_pickup_event) + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime.datetime, + end_date: datetime.datetime, + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + return [ + async_get_calendar_event_from_pickup_event(event) + for event in self.coordinator.data[self._account.account_id] + ] diff --git a/homeassistant/components/ridwell/coordinator.py b/homeassistant/components/ridwell/coordinator.py index a3b83c70aa..9561cd26e4 100644 --- a/homeassistant/components/ridwell/coordinator.py +++ b/homeassistant/components/ridwell/coordinator.py @@ -22,14 +22,14 @@ UPDATE_INTERVAL = timedelta(hours=1) class RidwellDataUpdateCoordinator( - DataUpdateCoordinator[dict[str, RidwellPickupEvent]] + DataUpdateCoordinator[dict[str, list[RidwellPickupEvent]]] ): """Class to manage fetching data from single endpoint.""" config_entry: ConfigEntry def __init__(self, hass: HomeAssistant, *, name: str) -> None: - """Initialize global data updater.""" + """Initialize.""" # These will be filled in by async_initialize; we give them these defaults to # avoid arduous typing checks down the line: self.accounts: dict[str, RidwellAccount] = {} @@ -38,13 +38,13 @@ class RidwellDataUpdateCoordinator( super().__init__(hass, LOGGER, name=name, update_interval=UPDATE_INTERVAL) - async def _async_update_data(self) -> dict[str, RidwellPickupEvent]: + async def _async_update_data(self) -> dict[str, list[RidwellPickupEvent]]: """Fetch the latest data from the source.""" data = {} async def async_get_pickups(account: RidwellAccount) -> None: """Get the latest pickups for an account.""" - data[account.account_id] = await account.async_get_next_pickup_event() + data[account.account_id] = await account.async_get_pickup_events() tasks = [async_get_pickups(account) for account in self.accounts.values()] results = await asyncio.gather(*tasks, return_exceptions=True) diff --git a/homeassistant/components/ridwell/diagnostics.py b/homeassistant/components/ridwell/diagnostics.py index 772efb87ac..f48861cee1 100644 --- a/homeassistant/components/ridwell/diagnostics.py +++ b/homeassistant/components/ridwell/diagnostics.py @@ -32,7 +32,11 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { "entry": entry.as_dict(), - "data": [dataclasses.asdict(event) for event in coordinator.data.values()], + "data": [ + dataclasses.asdict(event) + for events in coordinator.data.values() + for event in events + ], }, TO_REDACT, ) diff --git a/homeassistant/components/ridwell/entity.py b/homeassistant/components/ridwell/entity.py index 29dd68e2a8..9c7ceee7f5 100644 --- a/homeassistant/components/ridwell/entity.py +++ b/homeassistant/components/ridwell/entity.py @@ -1,8 +1,12 @@ """Define a base Ridwell entity.""" +from __future__ import annotations + +from datetime import date + from aioridwell.model import RidwellAccount, RidwellPickupEvent from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -18,7 +22,6 @@ class RidwellEntity(CoordinatorEntity[RidwellDataUpdateCoordinator]): self, coordinator: RidwellDataUpdateCoordinator, account: RidwellAccount, - description: EntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -31,10 +34,12 @@ class RidwellEntity(CoordinatorEntity[RidwellDataUpdateCoordinator]): manufacturer="Ridwell", name="Ridwell", ) - self._attr_unique_id = f"{account.account_id}_{description.key}" - self.entity_description = description @property def next_pickup_event(self) -> RidwellPickupEvent: """Get the next pickup event.""" - return self.coordinator.data[self._account.account_id] + return next( + event + for event in self.coordinator.data[self._account.account_id] + if event.pickup_date >= date.today() + ) diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py index 05cee54ba9..1eba555e95 100644 --- a/homeassistant/components/ridwell/sensor.py +++ b/homeassistant/components/ridwell/sensor.py @@ -27,7 +27,7 @@ ATTR_QUANTITY = "quantity" SENSOR_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_NEXT_PICKUP, - name="Ridwell pickup", + name="Next Ridwell pickup", device_class=SensorDeviceClass.DATE, ) @@ -54,9 +54,10 @@ class RidwellSensor(RidwellEntity, SensorEntity): description: SensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(coordinator, account, description) + super().__init__(coordinator, account) - self._attr_name = f"{description.name} ({account.address['street1']})" + self._attr_unique_id = f"{account.account_id}_{description.key}" + self.entity_description = description @property def extra_state_attributes(self) -> Mapping[str, Any]: diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py index f16bbaebab..7a948f8b88 100644 --- a/homeassistant/components/ridwell/switch.py +++ b/homeassistant/components/ridwell/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aioridwell.errors import RidwellError -from aioridwell.model import EventState +from aioridwell.model import EventState, RidwellAccount from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -38,7 +38,19 @@ async def async_setup_entry( class RidwellSwitch(RidwellEntity, SwitchEntity): - """Define a Ridwell button.""" + """Define a Ridwell switch.""" + + def __init__( + self, + coordinator: RidwellDataUpdateCoordinator, + account: RidwellAccount, + description: SwitchEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator, account) + + self._attr_unique_id = f"{account.account_id}_{description.key}" + self.entity_description = description @property def is_on(self) -> bool: diff --git a/tests/components/ridwell/conftest.py b/tests/components/ridwell/conftest.py index e86243da53..57d485d428 100644 --- a/tests/components/ridwell/conftest.py +++ b/tests/components/ridwell/conftest.py @@ -3,6 +3,7 @@ from datetime import date from unittest.mock import AsyncMock, Mock, patch from aioridwell.model import EventState, RidwellPickup, RidwellPickupEvent +from freezegun import freeze_time import pytest from homeassistant.components.ridwell.const import DOMAIN @@ -28,14 +29,16 @@ def account_fixture(): "state": "New York", "postal_code": "10001", }, - async_get_next_pickup_event=AsyncMock( - return_value=RidwellPickupEvent( - None, - "event_123", - date(2022, 1, 24), - [RidwellPickup("Plastic Film", "offer_123", 1, "product_123", 1)], - EventState.INITIALIZED, - ) + async_get_pickup_events=AsyncMock( + return_value=[ + RidwellPickupEvent( + None, + "event_123", + date(2022, 1, 24), + [RidwellPickup("Plastic Film", "offer_123", 1, "product_123", 1)], + EventState.INITIALIZED, + ) + ] ), ) @@ -77,6 +80,8 @@ async def mock_aioridwell_fixture(hass, client, config): ), patch( "homeassistant.components.ridwell.coordinator.async_get_client", return_value=client, + ), freeze_time( + "2022-01-01" ): yield diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index e73b352f3d..caac488041 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -32,7 +32,7 @@ async def test_entry_diagnostics( "_async_request": None, "event_id": "event_123", "pickup_date": { - "__type": "", + "__type": "", "isoformat": "2022-01-24", }, "pickups": [ From 8b7594ae08c2b69d6c2a04644e9e8862e3e5efbe Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Tue, 28 Mar 2023 00:33:32 -0700 Subject: [PATCH 0225/1165] Look up todoist collaborators only when adding new task (#87957) * Look up collaborators only when adding new task. Also fixed a few api call arguments that were incorrect. The `labels` key should have been a list of strings and the `assignee` key should have been `assignee_id`. * Add missing type in test. * Remove print --- homeassistant/components/todoist/calendar.py | 24 +++------- tests/components/todoist/test_calendar.py | 49 +++++++++++++++++--- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 02459b429c..645fea865e 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -1,9 +1,7 @@ """Support for Todoist task management (https://todoist.com).""" from __future__ import annotations -import asyncio from datetime import date, datetime, timedelta -from itertools import chain import logging from typing import Any import uuid @@ -117,8 +115,6 @@ async def async_setup_platform( # Look up IDs based on (lowercase) names. project_id_lookup = {} - label_id_lookup = {} - collaborator_id_lookup = {} api = TodoistAPIAsync(token) @@ -126,9 +122,6 @@ async def async_setup_platform( # Grab all projects. projects = await api.get_projects() - collaborator_tasks = (api.get_collaborators(project.id) for project in projects) - collaborators = list(chain.from_iterable(await asyncio.gather(*collaborator_tasks))) - # Grab all labels labels = await api.get_labels() @@ -142,13 +135,6 @@ async def async_setup_platform( # Cache the names so we can easily look up name->ID. project_id_lookup[project.name.lower()] = project.id - # Cache all label names - label_id_lookup = {label.name.lower(): label.id for label in labels} - - collaborator_id_lookup = { - collab.name.lower(): collab.id for collab in collaborators - } - # Check config for more projects. extra_projects: list[CustomProject] = config[CONF_EXTRA_PROJECTS] for extra_project in extra_projects: @@ -194,14 +180,16 @@ async def async_setup_platform( data: dict[str, Any] = {"project_id": project_id} if task_labels := call.data.get(LABELS): - data["label_ids"] = [ - label_id_lookup[label.lower()] for label in task_labels - ] + data["labels"] = task_labels if ASSIGNEE in call.data: + collaborators = await api.get_collaborators(project_id) + collaborator_id_lookup = { + collab.name.lower(): collab.id for collab in collaborators + } task_assignee = call.data[ASSIGNEE].lower() if task_assignee in collaborator_id_lookup: - data["assignee"] = collaborator_id_lookup[task_assignee] + data["assignee_id"] = collaborator_id_lookup[task_assignee] else: raise ValueError( f"User is not part of the shared project. user: {task_assignee}" diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index adf0f8a14b..9c0680d144 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -1,14 +1,21 @@ """Unit tests for the Todoist calendar platform.""" -from datetime import datetime, timedelta +from datetime import timedelta from http import HTTPStatus from unittest.mock import AsyncMock, patch import urllib import pytest -from todoist_api_python.models import Due, Label, Project, Task +from todoist_api_python.models import Collaborator, Due, Label, Project, Task from homeassistant import setup -from homeassistant.components.todoist.calendar import DOMAIN +from homeassistant.components.todoist.const import ( + ASSIGNEE, + CONTENT, + DOMAIN, + LABELS, + PROJECT_NAME, + SERVICE_NEW_TASK, +) from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -30,9 +37,7 @@ def mock_task() -> Task: created_at="2021-10-01T00:00:00", creator_id="1", description="A task", - due=Due( - is_recurring=False, date=datetime.now().strftime("%Y-%m-%d"), string="today" - ), + due=Due(is_recurring=False, date=dt.now().strftime("%Y-%m-%d"), string="today"), id="1", labels=["Label1"], order=1, @@ -68,7 +73,9 @@ def mock_api(task) -> AsyncMock: api.get_labels.return_value = [ Label(id="1", name="Label1", color="1", order=1, is_favorite=False) ] - api.get_collaborators.return_value = [] + api.get_collaborators.return_value = [ + Collaborator(email="user@gmail.com", id="1", name="user") + ] api.get_tasks.return_value = [task] return api @@ -193,3 +200,31 @@ async def test_all_day_event( } ] assert events == expected + + +@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +async def test_create_task_service_call(todoist_api, hass: HomeAssistant, api) -> None: + """Test api is called correctly after a new task service call.""" + todoist_api.return_value = api + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + } + }, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_NEW_TASK, + {ASSIGNEE: "user", CONTENT: "task", LABELS: ["Label1"], PROJECT_NAME: "Name"}, + ) + await hass.async_block_till_done() + + api.add_task.assert_called_with( + "task", project_id="12345", labels=["Label1"], assignee_id="1" + ) From 706e8d56128787478c0f1a28061005db097a8a5e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 28 Mar 2023 09:35:09 +0200 Subject: [PATCH 0226/1165] Add product calculation to Group sensor (#87373) * Group product * config flow --- homeassistant/components/group/config_flow.py | 1 + homeassistant/components/group/sensor.py | 14 ++++++++++++++ tests/components/group/test_sensor.py | 3 +++ 3 files changed, 18 insertions(+) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 069f74bf70..53a8fd0626 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -31,6 +31,7 @@ _STATISTIC_MEASURES = [ selector.SelectOptionDict(value="last", label="Most recently updated"), selector.SelectOptionDict(value="range", label="Statistical range"), selector.SelectOptionDict(value="sum", label="Sum"), + selector.SelectOptionDict(value="product", label="Product"), ] diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 265e1640d0..4c6e8dccc1 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -54,6 +54,7 @@ ATTR_LAST = "last" ATTR_LAST_ENTITY_ID = "last_entity_id" ATTR_RANGE = "range" ATTR_SUM = "sum" +ATTR_PRODUCT = "product" SENSOR_TYPES = { ATTR_MIN_VALUE: "min", ATTR_MAX_VALUE: "max", @@ -62,6 +63,7 @@ SENSOR_TYPES = { ATTR_LAST: "last", ATTR_RANGE: "range", ATTR_SUM: "sum", + ATTR_PRODUCT: "product", } SENSOR_TYPE_TO_ATTR = {v: k for k, v in SENSOR_TYPES.items()} @@ -226,6 +228,17 @@ def calc_sum( return {}, result +def calc_product( + sensor_values: list[tuple[str, float, State]] +) -> tuple[dict[str, str | None], float]: + """Calculate a product of values.""" + result = 1.0 + for _, sensor_value, _ in sensor_values: + result *= sensor_value + + return {}, result + + CALC_TYPES: dict[ str, Callable[ @@ -239,6 +252,7 @@ CALC_TYPES: dict[ "last": calc_last, "range": calc_range, "sum": calc_sum, + "product": calc_product, } diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 5f85aa6485..39c9b788d5 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the Group Sensor platform.""" from __future__ import annotations +from math import prod import statistics from typing import Any from unittest.mock import patch @@ -45,6 +46,7 @@ MEAN = statistics.mean(VALUES) MEDIAN = statistics.median(VALUES) RANGE = max(VALUES) - min(VALUES) SUM_VALUE = sum(VALUES) +PRODUCT_VALUE = prod(VALUES) @pytest.mark.parametrize( @@ -57,6 +59,7 @@ SUM_VALUE = sum(VALUES) ("last", VALUES[2], {ATTR_LAST_ENTITY_ID: "sensor.test_3"}), ("range", RANGE, {}), ("sum", SUM_VALUE, {}), + ("product", PRODUCT_VALUE, {}), ], ) async def test_sensors( From 5e03272821d6923353b3554ec51d8f70aa9e24c7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 09:36:34 +0200 Subject: [PATCH 0227/1165] Bump pychromecast to 13.0.6 (#90390) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index cc4a130a25..be80ca340f 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["pychromecast==13.0.4"], + "requirements": ["pychromecast==13.0.6"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e0df5121a0..c3dfa944f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1540,7 +1540,7 @@ pycfdns==2.0.1 pychannels==1.2.3 # homeassistant.components.cast -pychromecast==13.0.4 +pychromecast==13.0.6 # homeassistant.components.pocketcasts pycketcasts==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ceafe5d173..6edc309c81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1128,7 +1128,7 @@ pybravia==0.3.2 pycfdns==2.0.1 # homeassistant.components.cast -pychromecast==13.0.4 +pychromecast==13.0.6 # homeassistant.components.comfoconnect pycomfoconnect==0.5.1 From 14ffda975893ebe897ef01e10458f941cec878ac Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 28 Mar 2023 09:37:07 +0200 Subject: [PATCH 0228/1165] Remove dependency on async_setup from mqtt integration (#87987) * Remove async_setup from mqtt integration * Final update common tests * Related tests init * Related tests diagnostics * Related tests config_flow * Cleanup and correct test * Keep websockets_api commands in async_setup --- homeassistant/components/mqtt/__init__.py | 143 +++---------- homeassistant/components/mqtt/config_flow.py | 2 +- homeassistant/components/mqtt/mixins.py | 2 +- tests/components/mqtt/test_common.py | 69 +++---- tests/components/mqtt/test_config_flow.py | 206 +++++++------------ tests/components/mqtt/test_diagnostics.py | 2 - tests/components/mqtt/test_init.py | 157 ++------------ 7 files changed, 153 insertions(+), 428 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 5a9eb7c3fc..24dc4b67cd 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -10,7 +10,7 @@ from typing import Any, cast import jinja2 import voluptuous as vol -from homeassistant import config as conf_util, config_entries +from homeassistant import config as conf_util from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -25,16 +25,10 @@ from homeassistant.const import ( ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import TemplateError, Unauthorized -from homeassistant.helpers import ( - config_validation as cv, - discovery_flow, - event, - template, -) +from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.reload import ( async_integration_yaml_config, async_reload_integration_platforms, @@ -52,11 +46,9 @@ from .client import ( # noqa: F401 subscribe, ) from .config_integration import ( - CONFIG_SCHEMA_BASE, CONFIG_SCHEMA_ENTRY, DEFAULT_VALUES, - DEPRECATED_CERTIFICATE_CONFIG_KEYS, - DEPRECATED_CONFIG_KEYS, + PLATFORM_CONFIG_SCHEMA_BASE, ) from .const import ( # noqa: F401 ATTR_PAYLOAD, @@ -99,7 +91,6 @@ from .models import ( # noqa: F401 from .util import ( async_create_certificate_temp_files, get_mqtt_data, - migrate_certificate_file_to_content, mqtt_config_entry_enabled, valid_publish_topic, valid_qos_schema, @@ -146,22 +137,22 @@ CONFIG_ENTRY_CONFIG_KEYS = [ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( - cv.deprecated(CONF_BIRTH_MESSAGE), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_BROKER), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_CERTIFICATE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_ID), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_CERT), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_KEY), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_DISCOVERY), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_DISCOVERY_PREFIX), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_KEEPALIVE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_PASSWORD), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_PORT), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_PROTOCOL), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_TLS_INSECURE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_USERNAME), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_WILL_MESSAGE), # Deprecated in HA Core 2022.3 - CONFIG_SCHEMA_BASE, + cv.removed(CONF_BIRTH_MESSAGE), # Removed in HA Core 2023.4 + cv.removed(CONF_BROKER), # Removed in HA Core 2023.4 + cv.removed(CONF_CERTIFICATE), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_ID), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_CERT), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_KEY), # Removed in HA Core 2023.4 + cv.removed(CONF_DISCOVERY), # Removed in HA Core 2022.3 + cv.removed(CONF_DISCOVERY_PREFIX), # Removed in HA Core 2023.4 + cv.removed(CONF_KEEPALIVE), # Removed in HA Core 2023.4 + cv.removed(CONF_PASSWORD), # Removed in HA Core 2023.4 + cv.removed(CONF_PORT), # Removed in HA Core 2023.4 + cv.removed(CONF_PROTOCOL), # Removed in HA Core 2023.4 + cv.removed(CONF_TLS_INSECURE), # Removed in HA Core 2023.4 + cv.removed(CONF_USERNAME), # Removed in HA Core 2023.4 + cv.removed(CONF_WILL_MESSAGE), # Removed in HA Core 2023.4 + PLATFORM_CONFIG_SCHEMA_BASE, ) }, extra=vol.ALLOW_EXTRA, @@ -197,34 +188,8 @@ async def _async_setup_discovery( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT protocol service.""" - mqtt_data = get_mqtt_data(hass, True) - - conf: ConfigType | None = config.get(DOMAIN) - websocket_api.async_register_command(hass, websocket_subscribe) websocket_api.async_register_command(hass, websocket_mqtt_info) - - if conf: - conf = dict(conf) - mqtt_data.config = conf - - if (mqtt_entry_status := mqtt_config_entry_enabled(hass)) is None: - # Create an import flow if the user has yaml configured entities etc. - # but no broker configuration. Note: The intention is not for this to - # import broker configuration from YAML because that has been deprecated. - discovery_flow.async_create_flow( - hass, - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={}, - ) - mqtt_data.reload_needed = True - elif mqtt_entry_status is False: - _LOGGER.info( - "MQTT will be not available until the config entry is enabled", - ) - mqtt_data.reload_needed = True - return True @@ -247,30 +212,15 @@ def _filter_entry_config(hass: HomeAssistant, entry: ConfigEntry) -> None: hass.config_entries.async_update_entry(entry, data=filtered_data) -async def _async_merge_basic_config( +async def _async_auto_mend_config( hass: HomeAssistant, entry: ConfigEntry, yaml_config: dict[str, Any] ) -> None: - """Merge basic options in configuration.yaml config with config entry. + """Mends config fetched from config entry and adds missing values. This mends incomplete migration from old version of HA Core. """ entry_updated = False entry_config = {**entry.data} - for key in DEPRECATED_CERTIFICATE_CONFIG_KEYS: - if key in yaml_config and key not in entry_config: - if ( - content := await hass.async_add_executor_job( - migrate_certificate_file_to_content, yaml_config[key] - ) - ) is not None: - entry_config[key] = content - entry_updated = True - - for key in DEPRECATED_CONFIG_KEYS: - if key in yaml_config and key not in entry_config: - entry_config[key] = yaml_config[key] - entry_updated = True - for key in MANDATORY_DEFAULT_VALUES: if key not in entry_config: entry_config[key] = DEFAULT_VALUES[key] @@ -298,17 +248,16 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - async def async_fetch_config( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any] | None: - """Fetch fresh MQTT yaml config from the hass config when (re)loading the entry.""" + """Fetch fresh MQTT yaml config from the hass config.""" mqtt_data = get_mqtt_data(hass) - if mqtt_data.reload_entry: - hass_config = await conf_util.async_hass_config_yaml(hass) - mqtt_data.config = CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) + hass_config = await conf_util.async_hass_config_yaml(hass) + mqtt_data.config = PLATFORM_CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) # Remove unknown keys from config entry data _filter_entry_config(hass, entry) - # Merge basic configuration, and add missing defaults for basic options - await _async_merge_basic_config(hass, entry, mqtt_data.config or {}) + # Add missing defaults to migrate older config entries + await _async_auto_mend_config(hass, entry, mqtt_data.config or {}) # Bail out if broker setting is missing if CONF_BROKER not in entry.data: _LOGGER.error("MQTT broker is not configured, please configure it") @@ -319,37 +268,6 @@ async def async_fetch_config( if (conf := mqtt_data.config) is None: conf = CONFIG_SCHEMA_ENTRY(dict(entry.data)) - # User has configuration.yaml config, warn about config entry overrides - elif any(key in conf for key in entry.data): - shared_keys = conf.keys() & entry.data.keys() - override = {k: entry.data[k] for k in shared_keys if conf[k] != entry.data[k]} - if CONF_PASSWORD in override: - override[CONF_PASSWORD] = "********" - if CONF_CLIENT_KEY in override: - override[CONF_CLIENT_KEY] = "-----PRIVATE KEY-----" - if override: - _LOGGER.warning( - ( - "Deprecated configuration settings found in configuration.yaml. " - "These settings from your configuration entry will override: %s" - ), - override, - ) - # Register a repair issue - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_broker_settings", - breaks_in_ha_version="2023.4.0", # Warning first added in 2022.11.0 - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_broker_settings", - translation_placeholders={ - "more_info_url": "https://www.home-assistant.io/integrations/mqtt/", - "deprecated_settings": str(shared_keys)[1:-1], - }, - ) - # Merge advanced configuration values from configuration.yaml conf = _merge_extended_config(entry, conf) return conf @@ -359,10 +277,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" mqtt_data = get_mqtt_data(hass, True) - # Merge basic configuration, and add missing defaults for basic options + # Fetch configuration and add missing defaults for basic options if (conf := await async_fetch_config(hass, entry)) is None: # Bail out return False + await async_create_certificate_temp_files(hass, dict(entry.data)) mqtt_data.client = MQTT(hass, entry, conf) # Restore saved subscriptions @@ -480,6 +399,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _reload_config(call: ServiceCall) -> None: """Reload the platforms.""" + # Fetch updated manual configured items and validate + config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} + mqtt_data.updated_config = config_yaml.get(DOMAIN, {}) + # Reload the modern yaml platforms mqtt_platforms = async_get_platforms(hass, DOMAIN) tasks = [ @@ -493,8 +416,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] await asyncio.gather(*tasks) - config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} - mqtt_data.updated_config = config_yaml.get(DOMAIN, {}) await asyncio.gather( *( [ diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 66424f2c3d..77c3856aac 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -588,7 +588,7 @@ async def async_get_broker_settings( current_user = user_input_basic.get(CONF_USERNAME) current_pass = user_input_basic.get(CONF_PASSWORD) else: - # Get default settings from entry or yaml (if any) + # Get default settings from entry (if any) current_broker = current_config.get(CONF_BROKER) current_port = current_config.get(CONF_PORT, DEFAULT_PORT) current_user = current_config.get(CONF_USERNAME) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index b52c57ce24..cecb4b88bc 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -247,7 +247,7 @@ def warn_for_legacy_schema(domain: str) -> Callable[[ConfigType], ConfigType]: ( "Manually configured MQTT %s(s) found under platform key '%s', " "please move to the mqtt integration key, see " - "https://www.home-assistant.io/integrations/%s.mqtt/#new_format" + "https://www.home-assistant.io/integrations/%s.mqtt/" ), domain, domain, diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 6d238a63f4..154f91974a 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -36,7 +36,6 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClient, MqttMockHAClientGenerator, MqttMockPahoClient @@ -109,14 +108,15 @@ async def help_setup_component( item += 1 topic = f"homeassistant/{domain}/item_{item}/config" async_fire_mqtt_message(hass, topic, json.dumps(comp)) + await hass.async_block_till_done() else: - await async_setup_component( - hass, - mqtt.DOMAIN, - config, + entry = MockConfigEntry( + domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"} ) + entry.add_to_hass(hass) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await entry.async_setup(hass) mqtt_mock = None - await hass.async_block_till_done() return mqtt_mock @@ -226,7 +226,7 @@ async def help_test_default_availability_payload( async def help_test_default_availability_list_payload( hass: HomeAssistant, - mqtt_mock_entry_with_no_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -243,7 +243,7 @@ async def help_test_default_availability_list_payload( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - await help_setup_component(hass, mqtt_mock_entry_with_no_config, domain, config) + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -1169,7 +1169,7 @@ async def help_test_entity_id_update_subscriptions( entity_registry = er.async_get(hass) mqtt_mock = await help_setup_component( - hass, mqtt_mock_entry_no_yaml_config, domain, config, True + hass, mqtt_mock_entry_no_yaml_config, domain, config, use_discovery=True ) assert mqtt_mock is not None @@ -1796,27 +1796,6 @@ async def help_test_reload_with_config( await hass.async_block_till_done() -async def help_test_entry_reload_with_new_config( - hass: HomeAssistant, tmp_path: Path, new_config: ConfigType -) -> None: - """Test reloading with supplied config.""" - mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - assert mqtt_config_entry.state is ConfigEntryState.LOADED - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump(new_config) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config - - with patch.object( - module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file - ), patch("paho.mqtt.client.Client") as mock_client: - mock_client().connect = lambda *args: 0 - # reload the config entry - assert await hass.config_entries.async_reload(mqtt_config_entry.entry_id) - assert mqtt_config_entry.state is ConfigEntryState.LOADED - await hass.async_block_till_done() - - async def help_test_reloadable( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1839,10 +1818,8 @@ async def help_test_reloadable( entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) mqtt_client_mock.connect.return_value = 0 - # We should call await mqtt.async_setup_entry(hass, entry) when async_setup - # is removed (this is planned with #87987). Until then we set up the mqtt component - # to test reload after the async_setup setup has set the initial config - await help_setup_component(hass, None, domain, old_config, use_discovery=False) + with patch("homeassistant.config.load_yaml_config_file", return_value=old_config): + await entry.async_setup(hass) assert hass.states.get(f"{domain}.test_old_1") assert hass.states.get(f"{domain}.test_old_2") @@ -1860,15 +1837,15 @@ async def help_test_reloadable( new_config = { mqtt.DOMAIN: {domain: [new_config_1, new_config_2, new_config_extra]}, } - module_hass_config.load_yaml_config_file.return_value = new_config - # Reload the mqtt entry with the new config - await hass.services.async_call( - "mqtt", - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value=new_config): + # Reload the mqtt entry with the new config + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() assert len(hass.states.async_all(domain)) == 3 @@ -1900,9 +1877,9 @@ async def help_test_unload_config_entry_with_platform( config_setup: dict[str, dict[str, Any]] = copy.deepcopy(config) config_setup[mqtt.DOMAIN][domain]["name"] = "config_setup" config_name = config_setup - await help_setup_component( - hass, mqtt_mock_entry_no_yaml_config, domain, config_setup - ) + + with patch("homeassistant.config.load_yaml_config_file", return_value=config_name): + await mqtt_mock_entry_no_yaml_config() # prepare setup through discovery discovery_setup = copy.deepcopy(config[mqtt.DOMAIN][domain]) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 99a90bac83..ae7c4089e5 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -9,13 +9,11 @@ from uuid import uuid4 import pytest import voluptuous as vol -import yaml -from homeassistant import config as hass_config, config_entries, data_entry_flow +from homeassistant import config_entries, data_entry_flow from homeassistant.components import mqtt from homeassistant.components.hassio import HassioServiceInfo from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient @@ -267,39 +265,13 @@ async def test_user_connection_fails( assert len(mock_finish_setup.mock_calls) == 0 -async def test_manual_config_starts_discovery_flow( - hass: HomeAssistant, - mock_try_connection: MqttMockPahoClient, - mock_finish_setup: MagicMock, -) -> None: - """Test manual config initiates a discovery flow.""" - # No flows in progress - assert hass.config_entries.flow.async_progress() == [] - - # MQTT config present in yaml config - assert await async_setup_component(hass, "mqtt", {"mqtt": {}}) - await hass.async_block_till_done() - assert len(mock_finish_setup.mock_calls) == 0 - - # There should now be a discovery flow - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["context"]["source"] == "integration_discovery" - assert flows[0]["handler"] == "mqtt" - assert flows[0]["step_id"] == "broker" - - +@pytest.mark.parametrize("hass_config", [{"mqtt": {"sensor": {"state_topic": "test"}}}]) async def test_manual_config_set( hass: HomeAssistant, mock_try_connection: MqttMockPahoClient, mock_finish_setup: MagicMock, ) -> None: """Test manual config does not create an entry, and entry can be setup late.""" - # MQTT config present in yaml config - assert await async_setup_component(hass, "mqtt", {"mqtt": {"broker": "bla"}}) - await hass.async_block_till_done() - # do not try to reload - hass.data["mqtt"].reload_needed = False assert len(mock_finish_setup.mock_calls) == 0 mock_try_connection.return_value = True @@ -1162,6 +1134,9 @@ async def test_options_bad_will_message_fails( } +@pytest.mark.parametrize( + "hass_config", [{"mqtt": {"sensor": [{"state_topic": "some-topic"}]}}] +) async def test_try_connection_with_advanced_parameters( hass: HomeAssistant, mock_try_connection_success: MqttMockPahoClient, @@ -1170,23 +1145,6 @@ async def test_try_connection_with_advanced_parameters( mock_process_uploaded_file: MagicMock, ) -> None: """Test config flow with advanced parameters from config.""" - - with open(tmp_path / "client.crt", "wb") as certfile: - certfile.write(MOCK_CLIENT_CERT) - with open(tmp_path / "client.key", "wb") as keyfile: - keyfile.write(MOCK_CLIENT_KEY) - - config = { - "certificate": "auto", - "tls_insecure": True, - "client_cert": str(tmp_path / "client.crt"), - "client_key": str(tmp_path / "client.key"), - } - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump({mqtt.DOMAIN: config}) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) config_entry.add_to_hass(hass) config_entry.data = { @@ -1195,6 +1153,10 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_USERNAME: "user", mqtt.CONF_PASSWORD: "pass", mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_CERTIFICATE: "auto", + mqtt.CONF_TLS_INSECURE: True, + mqtt.CONF_CLIENT_CERT: MOCK_CLIENT_CERT.decode(encoding="utf-8)"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), mqtt.CONF_WS_PATH: "/path/", mqtt.CONF_WS_HEADERS: {"h1": "v1", "h2": "v2"}, mqtt.CONF_KEEPALIVE: 30, @@ -1212,95 +1174,81 @@ async def test_try_connection_with_advanced_parameters( mqtt.ATTR_RETAIN: False, }, } + # Test default/suggested values from config + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "broker" + defaults = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + "set_client_cert": True, + "set_ca_cert": "auto", + } + suggested = { + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "pass", + mqtt.CONF_TLS_INSECURE: True, + mqtt.CONF_PROTOCOL: "3.1.1", + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_PATH: "/path/", + mqtt.CONF_WS_HEADERS: '{"h1":"v1","h2":"v2"}', + } + for k, v in defaults.items(): + assert get_default(result["data_schema"].schema, k) == v + for k, v in suggested.items(): + assert get_suggested(result["data_schema"].schema, k) == v - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - # Test default/suggested values from config - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "broker" - defaults = { - mqtt.CONF_BROKER: "test-broker", - mqtt.CONF_PORT: 1234, - "set_client_cert": True, + # test we can change username and password + # as it was configured as auto in configuration.yaml is is migrated now + mock_try_connection_success.reset_mock() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "us3r", + mqtt.CONF_PASSWORD: "p4ss", "set_ca_cert": "auto", - } - suggested = { - mqtt.CONF_USERNAME: "user", - mqtt.CONF_PASSWORD: "pass", + "set_client_cert": True, mqtt.CONF_TLS_INSECURE: True, - mqtt.CONF_PROTOCOL: "3.1.1", mqtt.CONF_TRANSPORT: "websockets", - mqtt.CONF_WS_PATH: "/path/", - mqtt.CONF_WS_HEADERS: '{"h1":"v1","h2":"v2"}', - } - for k, v in defaults.items(): - assert get_default(result["data_schema"].schema, k) == v - for k, v in suggested.items(): - assert get_suggested(result["data_schema"].schema, k) == v + mqtt.CONF_WS_PATH: "/new/path", + mqtt.CONF_WS_HEADERS: '{"h3": "v3"}', + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "options" + await hass.async_block_till_done() - # test the client cert and key were migrated to the entry - assert config_entry.data[mqtt.CONF_CLIENT_CERT] == MOCK_CLIENT_CERT.decode( - "utf-8" - ) - assert config_entry.data[mqtt.CONF_CLIENT_KEY] == MOCK_CLIENT_KEY.decode( - "utf-8" - ) - assert config_entry.data[mqtt.CONF_CERTIFICATE] == "auto" + # check if the username and password was set from config flow and not from configuration.yaml + assert mock_try_connection_success.username_pw_set.mock_calls[0][1] == ( + "us3r", + "p4ss", + ) + # check if tls_insecure_set is called + assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,) - # test we can change username and password - # as it was configured as auto in configuration.yaml is is migrated now - mock_try_connection_success.reset_mock() - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - mqtt.CONF_PORT: 2345, - mqtt.CONF_USERNAME: "us3r", - mqtt.CONF_PASSWORD: "p4ss", - "set_ca_cert": "auto", - "set_client_cert": True, - mqtt.CONF_TLS_INSECURE: True, - mqtt.CONF_TRANSPORT: "websockets", - mqtt.CONF_WS_PATH: "/new/path", - mqtt.CONF_WS_HEADERS: '{"h3": "v3"}', - }, - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {} - assert result["step_id"] == "options" - await hass.async_block_till_done() + # check if the ca certificate settings were not set during connection test + assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ + "certfile" + ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_CERT) + assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ + "keyfile" + ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_KEY) - # check if the username and password was set from config flow and not from configuration.yaml - assert mock_try_connection_success.username_pw_set.mock_calls[0][1] == ( - "us3r", - "p4ss", - ) - # check if tls_insecure_set is called - assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,) - - # check if the ca certificate settings were not set during connection test - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "certfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_CERT) - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "keyfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_KEY) - - # check if websockets options are set - assert mock_try_connection_success.ws_set_options.mock_calls[0][1] == ( - "/new/path", - {"h3": "v3"}, - ) - - # Accept default option - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + # check if websockets options are set + assert mock_try_connection_success.ws_set_options.mock_calls[0][1] == ( + "/new/path", + {"h3": "v3"}, + ) + # Accept default option + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() async def test_setup_with_advanced_settings( diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index 780be72925..cfc6f069b0 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -25,8 +25,6 @@ default_config = { "port": 1883, "protocol": "3.1.1", "transport": "tcp", - "ws_headers": {}, - "ws_path": "/", "will_message": { "payload": "offline", "qos": 0, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 9fbca57e3a..cdc31429e2 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -5,18 +5,15 @@ import copy from datetime import datetime, timedelta from functools import partial import json -from pathlib import Path import ssl from typing import Any, TypedDict from unittest.mock import ANY, MagicMock, call, mock_open, patch import pytest import voluptuous as vol -import yaml -from homeassistant import config as module_hass_config from homeassistant.components import mqtt -from homeassistant.components.mqtt import CONFIG_SCHEMA, debug_info +from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.client import EnsureJobAfterCooldown from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.components.mqtt.models import MessageCallbackType, ReceiveMessage @@ -40,10 +37,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .test_common import ( - help_test_entry_reload_with_new_config, - help_test_validate_platform_config, -) +from .test_common import help_test_validate_platform_config from tests.common import ( MockConfigEntry, @@ -1529,18 +1523,17 @@ async def test_subscribed_at_highest_qos( async def test_reload_entry_with_restored_subscriptions( hass: HomeAssistant, - tmp_path: Path, mqtt_client_mock: MqttMockPahoClient, record_calls: MessageCallbackType, calls: list[ReceiveMessage], ) -> None: """Test reloading the config entry with with subscriptions restored.""" - + # Setup the MQTT entry entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) mqtt_client_mock.connect.return_value = 0 - assert await mqtt.async_setup_entry(hass, entry) - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + await entry.async_setup(hass) await mqtt.async_subscribe(hass, "test-topic", record_calls) await mqtt.async_subscribe(hass, "wild/+/card", record_calls) @@ -1557,10 +1550,10 @@ async def test_reload_entry_with_restored_subscriptions( calls.clear() # Reload the entry - config_yaml_new = {} - await help_test_entry_reload_with_new_config(hass, tmp_path, config_yaml_new) - - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + assert await hass.config_entries.async_reload(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + await hass.async_block_till_done() async_fire_mqtt_message(hass, "test-topic", "test-payload2") async_fire_mqtt_message(hass, "wild/any/card", "wild-card-payload2") @@ -1574,10 +1567,10 @@ async def test_reload_entry_with_restored_subscriptions( calls.clear() # Reload the entry again - config_yaml_new = {} - await help_test_entry_reload_with_new_config(hass, tmp_path, config_yaml_new) - - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + assert await hass.config_entries.async_reload(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + await hass.async_block_till_done() async_fire_mqtt_message(hass, "test-topic", "test-payload3") async_fire_mqtt_message(hass, "wild/any/card", "wild-card-payload3") @@ -1804,55 +1797,6 @@ async def test_handle_message_callback( assert callbacks[0].payload == "test-payload" -async def test_setup_override_configuration( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path -) -> None: - """Test override setup from configuration entry.""" - calls_username_password_set = [] - - def mock_usename_password_set(username: str, password: str) -> None: - calls_username_password_set.append((username, password)) - - # Mock password setup from config - config = { - "username": "someuser", - "password": "someyamlconfiguredpassword", - "protocol": "3.1", - } - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump({mqtt.DOMAIN: config}) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config - - with patch.object(module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - # Mock config entry - entry = MockConfigEntry( - domain=mqtt.DOMAIN, - data={mqtt.CONF_BROKER: "test-broker", "password": "somepassword"}, - ) - entry.add_to_hass(hass) - - with patch("paho.mqtt.client.Client") as mock_client: - mock_client().username_pw_set = mock_usename_password_set - mock_client.on_connect(return_value=0) - await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await entry.async_setup(hass) - await hass.async_block_till_done() - - assert ( - "Deprecated configuration settings found in configuration.yaml. " - "These settings from your configuration entry will override:" - in caplog.text - ) - - # Check if the protocol was set to 3.1 from configuration.yaml - assert mock_client.call_args[1]["protocol"] == 3 - - # Check if the password override worked - assert calls_username_password_set[0][0] == "someuser" - assert calls_username_password_set[0][1] == "somepassword" - - @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_manual_mqtt_with_platform_key( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -2312,39 +2256,10 @@ async def test_mqtt_subscribes_topics_on_connect( mqtt_client_mock.subscribe.assert_any_call("still/pending", 1) -async def test_setup_entry_with_config_override( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test if the MQTT component loads with no config and config entry can be setup.""" - data = ( - '{ "device":{"identifiers":["0AFFD2"]},' - ' "state_topic": "foobar/sensor",' - ' "unique_id": "unique" }' - ) - - # mqtt present in yaml config - assert await async_setup_component(hass, mqtt.DOMAIN, {}) - await hass.async_block_till_done() - - # User sets up a config entry - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Discover a device to verify the entry was setup correctly - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) - await hass.async_block_till_done() - - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) - assert device_entry is not None - - async def test_update_incomplete_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, caplog: pytest.LogCaptureFixture, ) -> None: @@ -2356,24 +2271,17 @@ async def test_update_incomplete_entry( ) # Config entry data is incomplete - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={"port": 1234}) - entry.add_to_hass(hass) - # Mqtt present in yaml config - config = {"broker": "yaml_broker"} - await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + entry.data = {"broker": "test-broker", "port": 1234} + await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() # Config entry data should now be updated assert dict(entry.data) == { + "broker": "test-broker", "port": 1234, "discovery_prefix": "homeassistant", - "broker": "yaml_broker", } - # Warnings about broker deprecated, but not about other keys with default values - assert ( - "The 'broker' option is deprecated, please remove it from your configuration" - in caplog.text - ) # Discover a device to verify the entry was setup correctly async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) @@ -3219,7 +3127,7 @@ async def test_subscribe_connection_status( # This warning and test is to be removed from HA core 2023.6 async def test_one_deprecation_warning_per_platform( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test a deprecation warning is is logged once per platform.""" @@ -3230,8 +3138,6 @@ async def test_one_deprecation_warning_per_platform( config2 = copy.deepcopy(config) config2["name"] = "test2" await async_setup_component(hass, platform, {platform: [config1, config2]}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() count = 0 for record in caplog.records: if record.levelname == "ERROR" and ( @@ -3242,13 +3148,6 @@ async def test_one_deprecation_warning_per_platform( assert count == 1 -async def test_config_schema_validation(hass: HomeAssistant) -> None: - """Test invalid platform options in the config schema do not pass the config validation.""" - config = {"mqtt": {"sensor": [{"some_illegal_topic": "mystate/topic/path"}]}} - with pytest.raises(vol.MultipleInvalid): - CONFIG_SCHEMA(config) - - @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) async def test_unload_config_entry( hass: HomeAssistant, @@ -3277,24 +3176,6 @@ async def test_unload_config_entry( assert "No ACK from MQTT server" not in caplog.text -@patch("homeassistant.components.mqtt.PLATFORMS", []) -async def test_setup_with_disabled_entry( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test setting up the platform with a disabled config entry.""" - # Try to setup the platform with a disabled config entry - config_entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={}, disabled_by=ConfigEntryDisabler.USER - ) - config_entry.add_to_hass(hass) - - config: ConfigType = {mqtt.DOMAIN: {}} - await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - - assert "MQTT will be not available until the config entry is enabled" in caplog.text - - @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_publish_or_subscribe_without_valid_config_entry( hass: HomeAssistant, record_calls: MessageCallbackType From c51ed4b3281368b6724610e6c7c4374f90367f52 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 28 Mar 2023 03:59:01 -0400 Subject: [PATCH 0229/1165] Redact secret zwave values in diagnostics (#90389) * redact secret zwave values from diagnostics * shhrink * rename --- .../components/zwave_js/diagnostics.py | 20 ++++++--- tests/components/zwave_js/test_diagnostics.py | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index acb87a239a..4f52c41a08 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -34,16 +34,23 @@ VALUES_TO_REDACT = ( ) -def redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType: - """Redact value of a Z-Wave value.""" +def _redacted_value(zwave_value: ValueDataType) -> ValueDataType: + """Return redacted value of a Z-Wave value.""" + redacted_value: ValueDataType = deepcopy(zwave_value) + redacted_value["value"] = REDACTED + return redacted_value + + +def optionally_redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType: + """Redact value of a Z-Wave value if it matches criteria to redact.""" # If the value has no value, there is nothing to redact if zwave_value.get("value") in (None, ""): return zwave_value + if zwave_value.get("metadata", {}).get("secret"): + return _redacted_value(zwave_value) for value_to_redact in VALUES_TO_REDACT: if value_matches_matcher(value_to_redact, zwave_value): - redacted_value: ValueDataType = deepcopy(zwave_value) - redacted_value["value"] = REDACTED - return redacted_value + return _redacted_value(zwave_value) return zwave_value @@ -51,7 +58,8 @@ def redact_node_state(node_state: NodeDataType) -> NodeDataType: """Redact node state.""" redacted_state: NodeDataType = deepcopy(node_state) redacted_state["values"] = [ - redact_value_of_zwave_value(zwave_value) for zwave_value in node_state["values"] + optionally_redact_value_of_zwave_value(zwave_value) + for zwave_value in node_state["values"] ] return redacted_state diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index 773b799cd6..c7a711d106 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -1,10 +1,14 @@ """Test the Z-Wave JS diagnostics.""" +import copy from unittest.mock import patch import pytest +from zwave_js_server.const import CommandClass from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.zwave_js.diagnostics import ( + REDACTED, ZwaveValueMatcher, async_get_device_diagnostics, ) @@ -179,3 +183,41 @@ async def test_device_diagnostics_missing_primary_value( assert air_entity["value_id"] == value.value_id assert air_entity["primary_value"] is None + + +async def test_device_diagnostics_secret_value( + hass: HomeAssistant, + client, + multisensor_6_state, + integration, + hass_client: ClientSessionGenerator, + version_state, +) -> None: + """Test that secret value in device level diagnostics gets redacted.""" + + def _find_ultraviolet_val(data: dict) -> dict: + """Find ultraviolet property value in data.""" + return next( + val + for val in data["values"] + if val["commandClass"] == CommandClass.SENSOR_MULTILEVEL + and val["property"] == PROPERTY_ULTRAVIOLET + ) + + node_state = copy.deepcopy(multisensor_6_state) + # Force a value to be secret so we can check if it gets redacted + secret_value = _find_ultraviolet_val(node_state) + secret_value["metadata"]["secret"] = True + node = Node(client, node_state) + client.driver.controller.nodes[node.node_id] = node + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device({get_device_id(client.driver, node)}) + assert device + + diagnostics_data = await get_diagnostics_for_device( + hass, hass_client, integration, device + ) + test_value = _find_ultraviolet_val(diagnostics_data["state"]) + assert test_value["value"] == REDACTED From 190393c6bbb741041a26c342a29bb60b51079396 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Tue, 28 Mar 2023 05:17:33 -0300 Subject: [PATCH 0230/1165] Improve Proxmox VE type hints (#90359) * Improves some type hints in Proxmox VE * update * update] * fix isort * Fix vm_id type * Fix vm_id type * Update homeassistant/components/proxmoxve/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Change initialization of _proxmox * Move definition of _proxmox to class level --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/proxmoxve/__init__.py | 54 ++++++++++++------- .../components/proxmoxve/binary_sensor.py | 22 +++++--- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 7ea4cac58d..2764f22b08 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from typing import Any from proxmoxer import AuthenticationError, ProxmoxAPI from proxmoxer.core import ResourceException @@ -185,14 +186,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def create_coordinator_container_vm( - hass, proxmox, host_name, node_name, vm_id, vm_type -): + hass: HomeAssistant, + proxmox: ProxmoxAPI, + host_name: str, + node_name: str, + vm_id: int, + vm_type: int, +) -> DataUpdateCoordinator[dict[str, Any] | None]: """Create and return a DataUpdateCoordinator for a vm/container.""" - async def async_update_data(): + async def async_update_data() -> dict[str, Any] | None: """Call the api and handle the response.""" - def poll_api(): + def poll_api() -> dict[str, Any] | None: """Call the api.""" vm_status = call_api_container_vm(proxmox, node_name, vm_id, vm_type) return vm_status @@ -216,7 +222,7 @@ def create_coordinator_container_vm( ) -def parse_api_container_vm(status): +def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: """Get the container or vm api data and return it formatted in a dictionary. It is implemented in this way to allow for more data to be added for sensors @@ -226,7 +232,12 @@ def parse_api_container_vm(status): return {"status": status["status"], "name": status["name"]} -def call_api_container_vm(proxmox, node_name, vm_id, machine_type): +def call_api_container_vm( + proxmox: ProxmoxAPI, + node_name: str, + vm_id: int, + machine_type: int, +) -> dict[str, Any] | None: """Make proper api calls.""" status = None @@ -247,12 +258,12 @@ class ProxmoxEntity(CoordinatorEntity): def __init__( self, coordinator: DataUpdateCoordinator, - unique_id, - name, - icon, - host_name, - node_name, - vm_id=None, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int | None = None, ) -> None: """Initialize the Proxmox entity.""" super().__init__(coordinator) @@ -292,7 +303,17 @@ class ProxmoxEntity(CoordinatorEntity): class ProxmoxClient: """A wrapper for the proxmoxer ProxmoxAPI client.""" - def __init__(self, host, port, user, realm, password, verify_ssl): + _proxmox: ProxmoxAPI + + def __init__( + self, + host: str, + port: int, + user: str, + realm: str, + password: str, + verify_ssl: bool, + ) -> None: """Initialize the ProxmoxClient.""" self._host = host @@ -302,10 +323,7 @@ class ProxmoxClient: self._password = password self._verify_ssl = verify_ssl - self._proxmox = None - self._connection_start_time = None - - def build_client(self): + def build_client(self) -> None: """Construct the ProxmoxAPI client. Allows inserting the realm within the `user` value. @@ -324,6 +342,6 @@ class ProxmoxClient: verify_ssl=self._verify_ssl, ) - def get_api_client(self): + def get_api_client(self) -> ProxmoxAPI: """Return the ProxmoxAPI client.""" return self._proxmox diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 9bb78d46ea..828c819114 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -51,7 +51,13 @@ async def async_setup_platform( add_entities(sensors) -def create_binary_sensor(coordinator, host_name, node_name, vm_id, name): +def create_binary_sensor( + coordinator, + host_name: str, + node_name: str, + vm_id: int, + name: str, +) -> ProxmoxBinarySensor: """Create a binary sensor based on the given data.""" return ProxmoxBinarySensor( coordinator=coordinator, @@ -72,12 +78,12 @@ class ProxmoxBinarySensor(ProxmoxEntity, BinarySensorEntity): def __init__( self, coordinator: DataUpdateCoordinator, - unique_id, - name, - icon, - host_name, - node_name, - vm_id, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int, ) -> None: """Create the binary sensor for vms or containers.""" super().__init__( @@ -85,7 +91,7 @@ class ProxmoxBinarySensor(ProxmoxEntity, BinarySensorEntity): ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return the state of the binary sensor.""" if (data := self.coordinator.data) is None: return None From e0424c83228631eaa0d72f14655a035305f95bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20=C3=98stergaard=20Nielsen?= Date: Tue, 28 Mar 2023 10:23:00 +0200 Subject: [PATCH 0231/1165] Use shorthand attributes in IHC (#90350) * typings to make linter happy * Moving device_class and native_value to init * remove is_on and use attr_is_on * Use try_parse_enum for sensor type * Remove not needed sensor_type Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/ihc/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/ihc/binary_sensor.py | 23 ++++++---------- homeassistant/components/ihc/sensor.py | 26 +++---------------- homeassistant/components/ihc/switch.py | 8 +----- 3 files changed, 13 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/ihc/binary_sensor.py b/homeassistant/components/ihc/binary_sensor.py index 48035d27a4..badf0f4e92 100644 --- a/homeassistant/components/ihc/binary_sensor.py +++ b/homeassistant/components/ihc/binary_sensor.py @@ -3,11 +3,15 @@ from __future__ import annotations from ihcsdk.ihccontroller import IHCController -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.enum import try_parse_enum from .const import CONF_INVERTING, DOMAIN, IHC_CONTROLLER from .ihcdevice import IHCDevice @@ -62,24 +66,13 @@ class IHCBinarySensor(IHCDevice, BinarySensorEntity): ) -> None: """Initialize the IHC binary sensor.""" super().__init__(ihc_controller, controller_id, name, ihc_id, product) - self._state = None - self._sensor_type = sensor_type + self._attr_device_class = try_parse_enum(BinarySensorDeviceClass, sensor_type) self.inverting = inverting - @property - def device_class(self): - """Return the class of this sensor.""" - return self._sensor_type - - @property - def is_on(self): - """Return true if the binary sensor is on/open.""" - return self._state - def on_ihc_change(self, ihc_id, value): """IHC resource has changed.""" if self.inverting: - self._state = not value + self._attr_is_on = not value else: - self._state = value + self._attr_is_on = value self.schedule_update_ha_state() diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py index d3c38687ca..c1210a358d 100644 --- a/homeassistant/components/ihc/sensor.py +++ b/homeassistant/components/ihc/sensor.py @@ -51,29 +51,11 @@ class IHCSensor(IHCDevice, SensorEntity): ) -> None: """Initialize the IHC sensor.""" super().__init__(ihc_controller, controller_id, name, ihc_id, product) - self._state = None - self._unit_of_measurement = unit - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return ( - SensorDeviceClass.TEMPERATURE - if self._unit_of_measurement in TEMPERATURE_UNITS - else None - ) - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_native_unit_of_measurement = unit + if unit in TEMPERATURE_UNITS: + self._attr_device_class = SensorDeviceClass.TEMPERATURE def on_ihc_change(self, ihc_id, value): """Handle IHC resource change.""" - self._state = value + self._attr_native_value = value self.schedule_update_ha_state() diff --git a/homeassistant/components/ihc/switch.py b/homeassistant/components/ihc/switch.py index 8e8edb0b7f..d4593dad57 100644 --- a/homeassistant/components/ihc/switch.py +++ b/homeassistant/components/ihc/switch.py @@ -59,12 +59,6 @@ class IHCSwitch(IHCDevice, SwitchEntity): super().__init__(ihc_controller, controller_id, name, ihc_id, product) self._ihc_off_id = ihc_off_id self._ihc_on_id = ihc_on_id - self._state = False - - @property - def is_on(self): - """Return true if switch is on.""" - return self._state async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -82,5 +76,5 @@ class IHCSwitch(IHCDevice, SwitchEntity): def on_ihc_change(self, ihc_id, value): """Handle IHC resource change.""" - self._state = value + self._attr_is_on = value self.schedule_update_ha_state() From 2fd872b253c112765fd1ac367ffa35871b05d79e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 23:02:08 -1000 Subject: [PATCH 0232/1165] Speed up profiler lru test (#90395) --- tests/components/profiler/test_init.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 2c283463b6..af642c779e 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -10,6 +10,7 @@ import py import pytest from homeassistant.components.profiler import ( + _LRU_CACHE_WRAPPER_OBJECT, CONF_SECONDS, SERVICE_DUMP_LOG_OBJECTS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, @@ -253,10 +254,15 @@ async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) domain_data = DomainData() assert hass.services.has_service(DOMAIN, SERVICE_LRU_STATS) - await hass.services.async_call(DOMAIN, SERVICE_LRU_STATS, blocking=True) + def _mock_by_type(type_): + if type_ == _LRU_CACHE_WRAPPER_OBJECT: + return [_dummy_test_lru_stats] + return [domain_data] + + with patch("objgraph.by_type", side_effect=_mock_by_type): + await hass.services.async_call(DOMAIN, SERVICE_LRU_STATS, blocking=True) assert "DomainData" in caplog.text assert "(0, 0)" in caplog.text assert "_dummy_test_lru_stats" in caplog.text assert "CacheInfo" in caplog.text - del domain_data From 23a1a8075c9f31f930dee0607f3e5b9b1f9802b5 Mon Sep 17 00:00:00 2001 From: Pascal Reeb Date: Tue, 28 Mar 2023 11:28:04 +0200 Subject: [PATCH 0233/1165] Add callback support to nuki (#88346) * feat(nuki): add callback support * fix(nuki): add webhook_enabled to tests * remove callback choice, add repair if it's https * black * fix(nuki): implemented feedback from pvizeli and frenck * remove unneded test change * remove issue_registry and http check * remove unneded response * add await to executor_job --- homeassistant/components/nuki/__init__.py | 111 +++++++++++++++++++- homeassistant/components/nuki/manifest.json | 1 + 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 9504d38c93..74245d30d4 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -3,9 +3,11 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta +from http import HTTPStatus import logging from typing import Generic, TypeVar +from aiohttp import web import async_timeout from pynuki import NukiBridge, NukiLock, NukiOpener from pynuki.bridge import InvalidCredentialsException @@ -13,10 +15,18 @@ from pynuki.device import NukiDevice from requests.exceptions import RequestException from homeassistant import exceptions +from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_TOKEN, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.network import get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -46,6 +56,29 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp return bridge.locks, bridge.openers +def _register_webhook(bridge: NukiBridge, entry_id: str, url: str) -> bool: + # Register HA URL as webhook if not already + callbacks = bridge.callback_list() + for item in callbacks["callbacks"]: + if entry_id in item["url"]: + if item["url"] == url: + return True + bridge.callback_remove(item["id"]) + + if bridge.callback_add(url)["success"]: + return True + + return False + + +def _remove_webhook(bridge: NukiBridge, entry_id: str) -> None: + # Remove webhook if set + callbacks = bridge.callback_list() + for item in callbacks["callbacks"]: + if entry_id in item["url"]: + bridge.callback_remove(item["id"]) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Nuki entry.""" @@ -88,6 +121,63 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sw_version=info["versions"]["firmwareVersion"], ) + async def handle_webhook( + hass: HomeAssistant, webhook_id: str, request: web.Request + ) -> web.Response: + """Handle webhook callback.""" + try: + data = await request.json() + except ValueError: + return web.Response(status=HTTPStatus.BAD_REQUEST) + + locks = hass.data[DOMAIN][entry.entry_id][DATA_LOCKS] + openers = hass.data[DOMAIN][entry.entry_id][DATA_OPENERS] + + devices = [x for x in locks + openers if x.nuki_id == data["nukiId"]] + if len(devices) == 1: + devices[0].update_from_callback(data) + + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator.async_set_updated_data(None) + + return web.Response(status=HTTPStatus.OK) + + webhook.async_register( + hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True + ) + + async def _stop_nuki(_: Event): + """Stop and remove the Nuki webhook.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, bridge, entry.entry_id + ) + except InvalidCredentialsException as err: + raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) + ) + + webhook_url = webhook.async_generate_path(entry.entry_id) + hass_url = get_url( + hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False + ) + url = f"{hass_url}{webhook_url}" + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _register_webhook, bridge, entry.entry_id, url + ) + except InvalidCredentialsException as err: + raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + coordinator = NukiCoordinator(hass, bridge, locks, openers) hass.data[DOMAIN][entry.entry_id] = { @@ -107,6 +197,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the Nuki entry.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, + hass.data[DOMAIN][entry.entry_id][DATA_BRIDGE], + entry.entry_id, + ) + except InvalidCredentialsException as err: + raise UpdateFailed( + f"Unable to remove callback. Invalid credentials for Bridge: {err}" + ) from err + except RequestException as err: + raise UpdateFailed( + f"Unable to remove callback. Error communicating with Bridge: {err}" + ) from err + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index e6b741d442..8b87816fb7 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -3,6 +3,7 @@ "name": "Nuki", "codeowners": ["@pschmitt", "@pvizeli", "@pree"], "config_flow": true, + "dependencies": ["webhook"], "dhcp": [ { "hostname": "nuki_bridge_*" From e617bfb1bb2436955ada65cd5c9b5589ca57b7f4 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Tue, 28 Mar 2023 05:51:35 -0400 Subject: [PATCH 0234/1165] Display unit of elevation in met config flow (#88283) * display unit of elevation in met config flow Co-authored-by: lijake8 Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * use NumberSelector for met config flow * met remove unused is_metric param --------- Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Co-authored-by: lijake8 --- homeassistant/components/met/__init__.py | 20 ++------------ homeassistant/components/met/config_flow.py | 29 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index c95c3abe05..c676f15336 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -18,15 +18,12 @@ from homeassistant.const import ( CONF_LONGITUDE, EVENT_CORE_CONFIG_UPDATE, Platform, - UnitOfLength, ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from homeassistant.util.unit_conversion import DistanceConverter -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( CONF_TRACK_HOME, @@ -102,9 +99,7 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize global Met data updater.""" self._unsub_track_home: Callable[[], None] | None = None - self.weather = MetWeatherData( - hass, config_entry.data, hass.config.units is METRIC_SYSTEM - ) + self.weather = MetWeatherData(hass, config_entry.data) self.weather.set_coordinates() update_interval = timedelta(minutes=randrange(55, 65)) @@ -142,13 +137,10 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): class MetWeatherData: """Keep data for Met.no weather entities.""" - def __init__( - self, hass: HomeAssistant, config: MappingProxyType[str, Any], is_metric: bool - ) -> None: + def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: """Initialise the weather entity data.""" self.hass = hass self._config = config - self._is_metric = is_metric self._weather_data: metno.MetWeatherData self.current_weather_data: dict = {} self.daily_forecast: list[dict] = [] @@ -165,14 +157,6 @@ class MetWeatherData: latitude = self._config[CONF_LATITUDE] longitude = self._config[CONF_LONGITUDE] elevation = self._config[CONF_ELEVATION] - if not self._is_metric: - elevation = int( - round( - DistanceConverter.convert( - elevation, UnitOfLength.FEET, UnitOfLength.METERS - ) - ) - ) coordinates = { "lat": str(latitude), diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 453c0a9cee..d8cb31077c 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -6,10 +6,21 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import ( + CONF_ELEVATION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + UnitOfLength, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) from .const import ( CONF_TRACK_HOME, @@ -47,7 +58,14 @@ def _get_data_schema( vol.Required( CONF_LONGITUDE, default=hass.config.longitude ): cv.longitude, - vol.Required(CONF_ELEVATION, default=hass.config.elevation): int, + vol.Required( + CONF_ELEVATION, default=hass.config.elevation + ): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=UnitOfLength.METERS, + ) + ), } ) # Not tracking home, default values come from config entry @@ -62,7 +80,12 @@ def _get_data_schema( ): cv.longitude, vol.Required( CONF_ELEVATION, default=config_entry.data.get(CONF_ELEVATION) - ): int, + ): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=UnitOfLength.METERS, + ) + ), } ) From 96dae587a9822ef3ee3e5c57905d3daa5ab812fb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Mar 2023 11:54:16 +0200 Subject: [PATCH 0235/1165] Fix ridwell tests (#90401) --- tests/components/ridwell/conftest.py | 3 --- tests/components/ridwell/test_diagnostics.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/components/ridwell/conftest.py b/tests/components/ridwell/conftest.py index 57d485d428..87ca00c37c 100644 --- a/tests/components/ridwell/conftest.py +++ b/tests/components/ridwell/conftest.py @@ -3,7 +3,6 @@ from datetime import date from unittest.mock import AsyncMock, Mock, patch from aioridwell.model import EventState, RidwellPickup, RidwellPickupEvent -from freezegun import freeze_time import pytest from homeassistant.components.ridwell.const import DOMAIN @@ -80,8 +79,6 @@ async def mock_aioridwell_fixture(hass, client, config): ), patch( "homeassistant.components.ridwell.coordinator.async_get_client", return_value=client, - ), freeze_time( - "2022-01-01" ): yield diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index caac488041..e73b352f3d 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -32,7 +32,7 @@ async def test_entry_diagnostics( "_async_request": None, "event_id": "event_123", "pickup_date": { - "__type": "", + "__type": "", "isoformat": "2022-01-24", }, "pickups": [ From 6fbdcac3232d4fd6d3c0170eb99691f18b132e58 Mon Sep 17 00:00:00 2001 From: BNolet Date: Tue, 28 Mar 2023 06:20:54 -0400 Subject: [PATCH 0236/1165] Fix setting color + brightness of Tuya lights (#88470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Check if changing TO a color mode Changing brightness alone does not change work mode, but changing brightness with a color value will keep the light in white mode. By verifying the new state has color or not, rather than the existing state being in color work mode, the light will change to color correctly. Tuya interprets HSV as including the brightness in the (v) value (which is generally what that's used for when setting HSV values). The brightness value given by Home Assistant is still used in this case. * Fix brightness-only turning colour mode to white This will take into account the case where brightness is the only parameter for both the case of colour mode and white mode. Tests passed after this change: * Brightness only (colour mode) ✅ * Brightness only (white mode) ✅ * Colour only (colour mode) ✅ * Colour only (white mode) ✅ * Colour temp only (colour mode) ✅ * Colour temp only (white mode) ✅ * Colour + brightness (colour mode) ✅ * Colour + brightness (white mode) ✅ * Colour temp + brightness (colour mode) ✅ * Colour temp + brightness (white mode) ✅ * Fix code formatting --- homeassistant/components/tuya/light.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 3546e45451..959a1834f8 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -499,9 +499,14 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ), }, ] - elif self._color_data_type and ( + + if self._color_data_type and ( ATTR_HS_COLOR in kwargs - or (ATTR_BRIGHTNESS in kwargs and self.color_mode == ColorMode.HS) + or ( + ATTR_BRIGHTNESS in kwargs + and self.color_mode == ColorMode.HS + and ATTR_COLOR_TEMP not in kwargs + ) ): if self._color_mode_dpcode: commands += [ @@ -542,11 +547,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): }, ] - if ( - ATTR_BRIGHTNESS in kwargs - and self.color_mode != ColorMode.HS - and self._brightness - ): + elif ATTR_BRIGHTNESS in kwargs and self._brightness: brightness = kwargs[ATTR_BRIGHTNESS] # If there is a min/max value, the brightness is actually limited. From be5714e3fd6f6f3ba3281a55a3b7d310943dce3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 00:24:12 -1000 Subject: [PATCH 0237/1165] Use slots for recorder tasks to reduce memory (#90387) --- homeassistant/components/recorder/tasks.py | 59 +++++++++++----------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index ef11885705..dfa6ce32d2 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -26,7 +26,8 @@ if TYPE_CHECKING: from .core import Recorder -class RecorderTask(abc.ABC): +@dataclass(slots=True) +class RecorderTask: """ABC for recorder tasks.""" commit_before = True @@ -36,7 +37,7 @@ class RecorderTask(abc.ABC): """Handle the task.""" -@dataclass +@dataclass(slots=True) class ChangeStatisticsUnitTask(RecorderTask): """Object to store statistics_id and unit to convert unit of statistics.""" @@ -54,7 +55,7 @@ class ChangeStatisticsUnitTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class ClearStatisticsTask(RecorderTask): """Object to store statistics_ids which for which to remove statistics.""" @@ -65,7 +66,7 @@ class ClearStatisticsTask(RecorderTask): statistics.clear_statistics(instance, self.statistic_ids) -@dataclass +@dataclass(slots=True) class UpdateStatisticsMetadataTask(RecorderTask): """Object to store statistics_id and unit for update of statistics metadata.""" @@ -83,7 +84,7 @@ class UpdateStatisticsMetadataTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class UpdateStatesMetadataTask(RecorderTask): """Task to update states metadata.""" @@ -99,7 +100,7 @@ class UpdateStatesMetadataTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class PurgeTask(RecorderTask): """Object to store information about purge task.""" @@ -125,7 +126,7 @@ class PurgeTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class PurgeEntitiesTask(RecorderTask): """Object to store entity information about purge task.""" @@ -140,7 +141,7 @@ class PurgeEntitiesTask(RecorderTask): instance.queue_task(PurgeEntitiesTask(self.entity_filter, self.purge_before)) -@dataclass +@dataclass(slots=True) class PerodicCleanupTask(RecorderTask): """An object to insert into the recorder to trigger cleanup tasks. @@ -152,7 +153,7 @@ class PerodicCleanupTask(RecorderTask): periodic_db_cleanups(instance) -@dataclass +@dataclass(slots=True) class StatisticsTask(RecorderTask): """An object to insert into the recorder queue to run a statistics task.""" @@ -167,7 +168,7 @@ class StatisticsTask(RecorderTask): instance.queue_task(StatisticsTask(self.start, self.fire_events)) -@dataclass +@dataclass(slots=True) class CompileMissingStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run a compile missing statistics.""" @@ -179,7 +180,7 @@ class CompileMissingStatisticsTask(RecorderTask): instance.queue_task(CompileMissingStatisticsTask()) -@dataclass +@dataclass(slots=True) class ImportStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run an import statistics task.""" @@ -199,7 +200,7 @@ class ImportStatisticsTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class AdjustStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run an adjust statistics task.""" @@ -229,7 +230,7 @@ class AdjustStatisticsTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class WaitTask(RecorderTask): """An object to insert into the recorder queue. @@ -243,7 +244,7 @@ class WaitTask(RecorderTask): instance._queue_watch.set() # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class DatabaseLockTask(RecorderTask): """An object to insert into the recorder queue to prevent writes to the database.""" @@ -256,7 +257,7 @@ class DatabaseLockTask(RecorderTask): instance._lock_database(self) # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class StopTask(RecorderTask): """An object to insert into the recorder queue to stop the event handler.""" @@ -267,7 +268,7 @@ class StopTask(RecorderTask): instance.stop_requested = True -@dataclass +@dataclass(slots=True) class EventTask(RecorderTask): """An event to be processed.""" @@ -280,7 +281,7 @@ class EventTask(RecorderTask): instance._process_one_event(self.event) -@dataclass +@dataclass(slots=True) class KeepAliveTask(RecorderTask): """A keep alive to be sent.""" @@ -292,7 +293,7 @@ class KeepAliveTask(RecorderTask): instance._send_keep_alive() -@dataclass +@dataclass(slots=True) class CommitTask(RecorderTask): """Commit the event session.""" @@ -304,7 +305,7 @@ class CommitTask(RecorderTask): instance._commit_event_session_or_retry() -@dataclass +@dataclass(slots=True) class AddRecorderPlatformTask(RecorderTask): """Add a recorder platform.""" @@ -321,7 +322,7 @@ class AddRecorderPlatformTask(RecorderTask): platforms[domain] = platform -@dataclass +@dataclass(slots=True) class SynchronizeTask(RecorderTask): """Ensure all pending data has been committed.""" @@ -335,7 +336,7 @@ class SynchronizeTask(RecorderTask): instance.hass.loop.call_soon_threadsafe(self.event.set) -@dataclass +@dataclass(slots=True) class PostSchemaMigrationTask(RecorderTask): """Post migration task to update schema.""" @@ -349,7 +350,7 @@ class PostSchemaMigrationTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class StatisticsTimestampMigrationCleanupTask(RecorderTask): """An object to insert into the recorder queue to run a statistics migration cleanup task.""" @@ -360,7 +361,7 @@ class StatisticsTimestampMigrationCleanupTask(RecorderTask): instance.queue_task(StatisticsTimestampMigrationCleanupTask()) -@dataclass +@dataclass(slots=True) class AdjustLRUSizeTask(RecorderTask): """An object to insert into the recorder queue to adjust the LRU size.""" @@ -371,7 +372,7 @@ class AdjustLRUSizeTask(RecorderTask): instance._adjust_lru_size() # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class StatesContextIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate states context ids.""" @@ -386,7 +387,7 @@ class StatesContextIDMigrationTask(RecorderTask): instance.queue_task(StatesContextIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EventsContextIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate events context ids.""" @@ -401,7 +402,7 @@ class EventsContextIDMigrationTask(RecorderTask): instance.queue_task(EventsContextIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EventTypeIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate event type ids.""" @@ -417,7 +418,7 @@ class EventTypeIDMigrationTask(RecorderTask): instance.queue_task(EventTypeIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EntityIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate entity_ids to StatesMeta.""" @@ -440,7 +441,7 @@ class EntityIDMigrationTask(RecorderTask): instance.queue_task(EntityIDPostMigrationTask()) -@dataclass +@dataclass(slots=True) class EntityIDPostMigrationTask(RecorderTask): """An object to insert into the recorder queue to cleanup after entity_ids migration.""" @@ -453,7 +454,7 @@ class EntityIDPostMigrationTask(RecorderTask): instance.queue_task(EntityIDPostMigrationTask()) -@dataclass +@dataclass(slots=True) class EventIdMigrationTask(RecorderTask): """An object to insert into the recorder queue to cleanup legacy event_ids in the states table. From ae41547b73e4d1354ec1c43dd0021531aeff564c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Mar 2023 03:25:44 -0700 Subject: [PATCH 0238/1165] Update calendar to always request start/end dates in local time rather than UTC (#90386) --- homeassistant/components/calendar/__init__.py | 2 +- homeassistant/components/google/calendar.py | 4 +-- .../components/local_calendar/calendar.py | 11 +++---- .../local_calendar/test_calendar.py | 33 ++++++++++++++++--- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 9af3244656..0b1c37cea5 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -523,7 +523,7 @@ class CalendarEventView(http.HomeAssistantView): try: calendar_event_list = await entity.async_get_events( - request.app["hass"], start_date, end_date + request.app["hass"], dt.as_local(start_date), dt.as_local(end_date) ) except HomeAssistantError as err: _LOGGER.debug("Error reading events: %s", err) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 1e1072940a..363b75c2c5 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -283,8 +283,8 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): "Unable to get events: Sync from server has not completed" ) return self.data.overlapping( - dt_util.as_local(start_date), - dt_util.as_local(end_date), + start_date, + end_date, ) @property diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 718c65ffce..4b6d9444fd 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -85,17 +85,16 @@ class LocalCalendarEntity(CalendarEntity): self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - events = self._calendar.timeline_tz(dt_util.DEFAULT_TIME_ZONE).overlapping( - dt_util.as_local(start_date), - dt_util.as_local(end_date), + events = self._calendar.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, ) return [_get_calendar_event(event) for event in events] async def async_update(self) -> None: """Update entity state with the next upcoming event.""" - events = self._calendar.timeline_tz(dt_util.DEFAULT_TIME_ZONE).active_after( - dt_util.now() - ) + now = dt_util.now() + events = self._calendar.timeline_tz(now.tzinfo).active_after(now) if event := next(events, None): self._event = _get_calendar_event(event) else: diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 6bdb58cf65..a2f13ea289 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -37,10 +37,27 @@ async def test_empty_calendar( } +@pytest.mark.parametrize( + ("dtstart", "dtend"), + [ + ("1997-07-14T18:00:00+01:00", "1997-07-15T05:00:00+01:00"), + ("1997-07-14T17:00:00+00:00", "1997-07-15T04:00:00+00:00"), + ("1997-07-14T11:00:00-06:00", "1997-07-14T22:00:00-06:00"), + ("1997-07-14T10:00:00-07:00", "1997-07-14T21:00:00-07:00"), + ], +) async def test_api_date_time_event( - ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn + ws_client: ClientFixture, + setup_integration: None, + get_events: GetEventsFn, + dtstart: str, + dtend: str, ) -> None: - """Test an event with a start/end date time.""" + """Test an event with a start/end date time. + + Events created in various timezones are ultimately returned relative + to local home assistant timezone. + """ client = await ws_client() await client.cmd_result( "create", @@ -48,8 +65,8 @@ async def test_api_date_time_event( "entity_id": TEST_ENTITY, "event": { "summary": "Bastille Day Party", - "dtstart": "1997-07-14T17:00:00+00:00", - "dtend": "1997-07-15T04:00:00+00:00", + "dtstart": dtstart, + "dtend": dtend, }, }, ) @@ -63,6 +80,8 @@ async def test_api_date_time_event( } ] + # Query events in UTC + # Time range before event events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T16:00:00Z") assert len(events) == 0 @@ -77,6 +96,12 @@ async def test_api_date_time_event( events = await get_events("1997-07-15T03:00:00Z", "1997-07-15T06:00:00Z") assert len(events) == 1 + # Query events overlapping with start and end but in another timezone + events = await get_events("1997-07-12T23:00:00-01:00", "1997-07-14T17:00:00-01:00") + assert len(events) == 1 + events = await get_events("1997-07-15T02:00:00-01:00", "1997-07-15T05:00:00-01:00") + assert len(events) == 1 + async def test_api_date_event( ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn From 3c3860c923b34cdd7b5c8442a0b9e9daefb14ba6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 12:34:25 +0200 Subject: [PATCH 0239/1165] Make OTBR use same channel as ZHA (#88546) --- .../silabs_multiprotocol_addon.py | 8 +++ homeassistant/components/otbr/config_flow.py | 23 ++++---- homeassistant/components/otbr/manifest.json | 4 +- homeassistant/components/otbr/util.py | 43 ++++++++++++++ .../components/otbr/websocket_api.py | 19 +++--- .../test_silabs_multiprotocol_addon.py | 11 ++++ tests/components/otbr/test_config_flow.py | 13 ++++- tests/components/otbr/test_util.py | 58 +++++++++++++++++++ tests/components/otbr/test_websocket_api.py | 26 ++++++--- 9 files changed, 171 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/otbr/util.py create mode 100644 tests/components/otbr/test_util.py diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 41f16462cd..ff2bf9138f 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -8,6 +8,7 @@ import logging from typing import Any import voluptuous as vol +import yarl from homeassistant import config_entries from homeassistant.components.hassio import ( @@ -74,6 +75,13 @@ def get_zigbee_socket() -> str: return f"socket://{hostname}:9999" +def is_multiprotocol_url(url: str) -> bool: + """Return if the URL points at the Multiprotocol add-on.""" + parsed = yarl.URL(url) + hostname = hostname_from_addon_slug(SILABS_MULTIPROTOCOL_ADDON_SLUG) + return parsed.host == hostname + + class BaseMultiPanFlow(FlowHandler, ABC): """Support configuring the Silicon Labs Multiprotocol add-on.""" diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 4247d5dbd6..434b9026ae 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_CHANNEL, DOMAIN +from .util import get_allowed_channel _LOGGER = logging.getLogger(__name__) @@ -27,13 +28,12 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _connect_and_create_dataset(self, url: str) -> None: - """Connect to the OTBR and create a dataset if it doesn't have one.""" - api = python_otbr_api.OTBR(url, async_get_clientsession(self.hass), 10) + async def _connect_and_set_dataset(self, otbr_url: str) -> None: + """Connect to the OTBR and create or apply a dataset if it doesn't have one.""" + api = python_otbr_api.OTBR(otbr_url, async_get_clientsession(self.hass), 10) if await api.get_active_dataset_tlvs() is None: - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL + allowed_channel = await get_allowed_channel(self.hass, otbr_url) + thread_dataset_channel = None thread_dataset_tlv = await async_get_preferred_dataset(self.hass) if thread_dataset_tlv: @@ -41,7 +41,9 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): thread_dataset_channel = int(channel_str, base=16) - if thread_dataset_tlv is not None and zha_channel == thread_dataset_channel: + if thread_dataset_tlv is not None and ( + not allowed_channel or allowed_channel == thread_dataset_channel + ): await api.set_active_dataset_tlvs(bytes.fromhex(thread_dataset_tlv)) else: _LOGGER.debug( @@ -49,7 +51,8 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): ) await api.create_active_dataset( python_otbr_api.OperationalDataSet( - channel=zha_channel, network_name="home-assistant" + channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL, + network_name="home-assistant", ) ) await api.set_enabled(True) @@ -66,7 +69,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: url = user_input[CONF_URL] try: - await self._connect_and_create_dataset(url) + await self._connect_and_set_dataset(url) except ( python_otbr_api.OTBRError, aiohttp.ClientError, @@ -108,7 +111,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="single_instance_allowed") try: - await self._connect_and_create_dataset(url) + await self._connect_and_set_dataset(url) except python_otbr_api.OTBRError as exc: _LOGGER.warning("Failed to communicate with OTBR@%s: %s", url, exc) return self.async_abort(reason="unknown") diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 2590e92210..8e9050ca9f 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -1,10 +1,10 @@ { "domain": "otbr", "name": "Open Thread Border Router", - "after_dependencies": ["hassio"], + "after_dependencies": ["hassio", "zha"], "codeowners": ["@home-assistant/core"], "config_flow": true, - "dependencies": ["thread"], + "dependencies": ["homeassistant_hardware", "thread"], "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py new file mode 100644 index 0000000000..b1a3ee11b8 --- /dev/null +++ b/homeassistant/components/otbr/util.py @@ -0,0 +1,43 @@ +"""Utility functions for the Open Thread Border Router integration.""" +from __future__ import annotations + +import contextlib + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) +from homeassistant.components.zha import api as zha_api +from homeassistant.core import HomeAssistant + + +def _get_zha_url(hass: HomeAssistant) -> str | None: + """Get ZHA radio path, or None if there's no ZHA config entry.""" + with contextlib.suppress(ValueError): + return zha_api.async_get_radio_path(hass) + return None + + +async def _get_zha_channel(hass: HomeAssistant) -> int | None: + """Get ZHA channel, or None if there's no ZHA config entry.""" + zha_network_settings: zha_api.NetworkBackup | None + with contextlib.suppress(ValueError): + zha_network_settings = await zha_api.async_get_network_settings(hass) + if not zha_network_settings: + return None + channel: int = zha_network_settings.network_info.channel + # ZHA uses channel 0 when no channel is set + return channel or None + + +async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: + """Return the allowed channel, or None if there's no restriction.""" + if not is_multiprotocol_url(otbr_url): + # The OTBR is not sharing the radio, no restriction + return None + + zha_url = _get_zha_url(hass) + if not zha_url or not is_multiprotocol_url(zha_url): + # ZHA is not configured or not sharing the radio with this OTBR, no restriction + return None + + return await _get_zha_channel(hass) diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index aa8c1dd2dd..cd4f8875e7 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from .const import DEFAULT_CHANNEL, DOMAIN +from .util import get_allowed_channel if TYPE_CHECKING: from . import OTBRData @@ -72,11 +73,8 @@ async def websocket_create_network( connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") return - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL - data: OTBRData = hass.data[DOMAIN] + channel = await get_allowed_channel(hass, data.url) or DEFAULT_CHANNEL try: await data.set_enabled(False) @@ -87,7 +85,7 @@ async def websocket_create_network( try: await data.create_active_dataset( python_otbr_api.OperationalDataSet( - channel=zha_channel, network_name="home-assistant" + channel=channel, network_name="home-assistant" ) ) except HomeAssistantError as exc: @@ -139,21 +137,18 @@ async def websocket_set_network( if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): thread_dataset_channel = int(channel_str, base=16) - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL + data: OTBRData = hass.data[DOMAIN] + allowed_channel = await get_allowed_channel(hass, data.url) - if thread_dataset_channel != zha_channel: + if allowed_channel and thread_dataset_channel != allowed_channel: connection.send_error( msg["id"], "channel_conflict", f"Can't connect to network on channel {thread_dataset_channel}, ZHA is " - f"using channel {zha_channel}", + f"using channel {allowed_channel}", ) return - data: OTBRData = hass.data[DOMAIN] - try: await data.set_enabled(False) except HomeAssistantError as exc: diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 424e4126e0..a195899136 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -795,3 +795,14 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_2( result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "zha_migration_failed" + + +def test_is_multiprotocol_url() -> None: + """Test is_multiprotocol_url.""" + assert silabs_multiprotocol_addon.is_multiprotocol_url( + "socket://core-silabs-multiprotocol:9999" + ) + assert silabs_multiprotocol_addon.is_multiprotocol_url( + "http://core-silabs-multiprotocol:8081" + ) + assert not silabs_multiprotocol_addon.is_multiprotocol_url("/dev/ttyAMA1") diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index ae49c63002..b788c93610 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -2,7 +2,7 @@ import asyncio from http import HTTPStatus from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch import aiohttp import pytest @@ -320,13 +320,22 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( aioclient_mock.post(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + networksettings = Mock() + networksettings.network_info.channel = 15 + with patch( "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", return_value=DATASET_CH16.hex(), ), patch( "homeassistant.components.otbr.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=networksettings, + ): result = await hass.config_entries.flow.async_init( otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA ) diff --git a/tests/components/otbr/test_util.py b/tests/components/otbr/test_util.py new file mode 100644 index 0000000000..af5306b358 --- /dev/null +++ b/tests/components/otbr/test_util.py @@ -0,0 +1,58 @@ +"""Test OTBR Utility functions.""" +from unittest.mock import Mock, patch + +from homeassistant.components import otbr +from homeassistant.core import HomeAssistant + +OTBR_MULTIPAN_URL = "http://core-silabs-multiprotocol:8081" +OTBR_NON_MULTIPAN_URL = "/dev/ttyAMA1" + + +async def test_get_allowed_channel(hass: HomeAssistant) -> None: + """Test get_allowed_channel.""" + + zha_networksettings = Mock() + zha_networksettings.network_info.channel = 15 + + # OTBR multipan + No ZHA -> no restriction + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + + # OTBR multipan + ZHA multipan empty settings -> no restriction + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=None, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + + # OTBR multipan + ZHA not multipan using channel 15 -> no restriction + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="/dev/ttyAMA1", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=zha_networksettings, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + + # OTBR multipan + ZHA multipan using channel 15 -> 15 + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=zha_networksettings, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) == 15 + + # OTBR not multipan + ZHA multipan using channel 15 -> no restriction + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=zha_networksettings, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_NON_MULTIPAN_URL) is None diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 8442162257..e6f492f5e5 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1,5 +1,5 @@ """Test OTBR Websocket API.""" -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest import python_otbr_api @@ -283,14 +283,24 @@ async def test_set_network_channel_conflict( dataset_store = await thread.dataset_store.async_get_store(hass) dataset_id = list(dataset_store.datasets)[0] - await websocket_client.send_json_auto_id( - { - "type": "otbr/set_network", - "dataset_id": dataset_id, - } - ) + networksettings = Mock() + networksettings.network_info.channel = 15 - msg = await websocket_client.receive_json() + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=networksettings, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + + msg = await websocket_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "channel_conflict" From 1c465b5ad07e5b9ab589d50297899d2191af3308 Mon Sep 17 00:00:00 2001 From: Olivier Ouellet <85790609+olivierouellet@users.noreply.github.com> Date: Tue, 28 Mar 2023 06:42:31 -0400 Subject: [PATCH 0240/1165] Add encoding configuration setting to REST and Scape (#90254) * Create new config parameter for default character encoding if no character encoding is declared * Changes suggested by gjohansson-ST * Added config flow for scape * Removed "character" * Change to create_async_httpx_client * Remove CONF_ENCODING from Scrape SENSOR_SCHEMA * Debug scrape test --- homeassistant/components/rest/__init__.py | 23 ++++++++++++++++--- homeassistant/components/rest/const.py | 2 ++ homeassistant/components/rest/data.py | 8 ++++--- homeassistant/components/rest/schema.py | 3 +++ .../components/scrape/config_flow.py | 11 ++++++++- homeassistant/components/scrape/const.py | 2 ++ homeassistant/components/scrape/strings.json | 12 ++++++---- tests/components/scrape/conftest.py | 9 +++++++- tests/components/scrape/test_config_flow.py | 9 ++++++++ 9 files changed, 67 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 37c483505b..637e9da6f9 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -41,7 +41,15 @@ from homeassistant.helpers.reload import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import COORDINATOR, DOMAIN, PLATFORM_IDX, REST, REST_DATA, REST_IDX +from .const import ( + CONF_ENCODING, + COORDINATOR, + DOMAIN, + PLATFORM_IDX, + REST, + REST_DATA, + REST_IDX, +) from .data import RestData from .schema import CONFIG_SCHEMA, RESOURCE_SCHEMA # noqa: F401 @@ -182,7 +190,7 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res headers: dict[str, str] | None = config.get(CONF_HEADERS) params: dict[str, str] | None = config.get(CONF_PARAMS) timeout: int = config[CONF_TIMEOUT] - + encoding: str = config[CONF_ENCODING] if resource_template is not None: resource_template.hass = hass resource = resource_template.async_render(parse_result=False) @@ -201,5 +209,14 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res auth = (username, password) return RestData( - hass, method, resource, auth, headers, params, payload, verify_ssl, timeout + hass, + method, + resource, + encoding, + auth, + headers, + params, + payload, + verify_ssl, + timeout, ) diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py index 5fd32d8fba..bdc0c5af49 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -5,6 +5,8 @@ DOMAIN = "rest" DEFAULT_METHOD = "GET" DEFAULT_VERIFY_SSL = True DEFAULT_FORCE_UPDATE = False +DEFAULT_ENCODING = "UTF-8" +CONF_ENCODING = "encoding" DEFAULT_BINARY_SENSOR_NAME = "REST Binary Sensor" DEFAULT_SENSOR_NAME = "REST Sensor" diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index c1990b2833..7a5d62694b 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -7,7 +7,7 @@ import httpx from homeassistant.core import HomeAssistant from homeassistant.helpers import template -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client DEFAULT_TIMEOUT = 10 @@ -22,6 +22,7 @@ class RestData: hass: HomeAssistant, method: str, resource: str, + encoding: str, auth: httpx.DigestAuth | tuple[str, str] | None, headers: dict[str, str] | None, params: dict[str, str] | None, @@ -33,6 +34,7 @@ class RestData: self._hass = hass self._method = method self._resource = resource + self._encoding = encoding self._auth = auth self._headers = headers self._params = params @@ -51,8 +53,8 @@ class RestData: async def async_update(self, log_errors: bool = True) -> None: """Get the latest data from REST service with provided method.""" if not self._async_client: - self._async_client = get_async_client( - self._hass, verify_ssl=self._verify_ssl + self._async_client = create_async_httpx_client( + self._hass, verify_ssl=self._verify_ssl, default_encoding=self._encoding ) rendered_headers = template.render_complex(self._headers, parse_result=False) diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index cfd8f8a385..8e0fa9de00 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -33,8 +33,10 @@ from homeassistant.helpers.template_entity import ( ) from .const import ( + CONF_ENCODING, CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, + DEFAULT_ENCODING, DEFAULT_FORCE_UPDATE, DEFAULT_METHOD, DEFAULT_VERIFY_SSL, @@ -57,6 +59,7 @@ RESOURCE_SCHEMA = { vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, } SENSOR_SCHEMA = { diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 419dd04f60..1e3635a010 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -60,7 +60,15 @@ from homeassistant.helpers.selector import ( ) from . import COMBINED_SCHEMA -from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN +from .const import ( + CONF_ENCODING, + CONF_INDEX, + CONF_SELECT, + DEFAULT_ENCODING, + DEFAULT_NAME, + DEFAULT_VERIFY_SSL, + DOMAIN, +) RESOURCE_SETUP = { vol.Required(CONF_RESOURCE): TextSelector( @@ -84,6 +92,7 @@ RESOURCE_SETUP = { vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector( NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) ), + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } SENSOR_SETUP = { diff --git a/homeassistant/components/scrape/const.py b/homeassistant/components/scrape/const.py index fc433ebb6f..cd64199fa2 100644 --- a/homeassistant/components/scrape/const.py +++ b/homeassistant/components/scrape/const.py @@ -6,11 +6,13 @@ from datetime import timedelta from homeassistant.const import Platform DOMAIN = "scrape" +DEFAULT_ENCODING = "UTF-8" DEFAULT_NAME = "Web scrape" DEFAULT_VERIFY_SSL = True DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) PLATFORMS = [Platform.SENSOR] +CONF_ENCODING = "encoding" CONF_SELECT = "select" CONF_INDEX = "index" diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 061518cb1d..052ef22848 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -16,14 +16,16 @@ "password": "[%key:common::config_flow::data::password%]", "headers": "Headers", "method": "Method", - "timeout": "Timeout" + "timeout": "Timeout", + "encoding": "Character encoding" }, "data_description": { "resource": "The URL to the website that contains the value", "authentication": "Type of the HTTP authentication. Either basic or digest", "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", "headers": "Headers to use for the web request", - "timeout": "Timeout for connection to website" + "timeout": "Timeout for connection to website", + "encoding": "Character encoding to use. Defaults to UTF-8" } }, "sensor": { @@ -110,14 +112,16 @@ "password": "[%key:component::scrape::config::step::user::data::password%]", "headers": "[%key:component::scrape::config::step::user::data::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", - "timeout": "[%key:component::scrape::config::step::user::data::timeout%]" + "timeout": "[%key:component::scrape::config::step::user::data::timeout%]", + "encoding": "[%key:component::scrape::config::step::user::data::encoding%]" }, "data_description": { "resource": "[%key:component::scrape::config::step::user::data_description::resource%]", "authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]", "headers": "[%key:component::scrape::config::step::user::data_description::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]", - "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]" + "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]", + "encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]" } } } diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py index fa90786ec2..5ad4f39844 100644 --- a/tests/components/scrape/conftest.py +++ b/tests/components/scrape/conftest.py @@ -9,7 +9,13 @@ import pytest from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD, DEFAULT_VERIFY_SSL -from homeassistant.components.scrape.const import CONF_INDEX, CONF_SELECT, DOMAIN +from homeassistant.components.scrape.const import ( + CONF_ENCODING, + CONF_INDEX, + CONF_SELECT, + DEFAULT_ENCODING, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_METHOD, @@ -38,6 +44,7 @@ async def get_config_to_integration_load() -> dict[str, Any]: CONF_METHOD: DEFAULT_METHOD, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, "sensor": [ { CONF_NAME: "Current version", diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index e12a7c15a0..e508937fed 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -9,8 +9,10 @@ from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.scrape import DOMAIN from homeassistant.components.scrape.const import ( + CONF_ENCODING, CONF_INDEX, CONF_SELECT, + DEFAULT_ENCODING, DEFAULT_VERIFY_SSL, ) from homeassistant.const import ( @@ -75,6 +77,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10.0, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", @@ -165,6 +168,7 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10.0, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", @@ -206,6 +210,7 @@ async def test_options_resource_flow( CONF_METHOD: DEFAULT_METHOD, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, CONF_USERNAME: "secret_username", CONF_PASSWORD: "secret_password", }, @@ -218,6 +223,7 @@ async def test_options_resource_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10.0, + CONF_ENCODING: "UTF-8", CONF_USERNAME: "secret_username", CONF_PASSWORD: "secret_password", "sensor": [ @@ -282,6 +288,7 @@ async def test_options_add_remove_sensor_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", @@ -341,6 +348,7 @@ async def test_options_add_remove_sensor_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Template", @@ -407,6 +415,7 @@ async def test_options_edit_sensor_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", From e4bb339a1e80eb5db9139568d035c4d60c0076ba Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Mar 2023 12:43:00 +0200 Subject: [PATCH 0241/1165] Add device info to Nextcloud integration (#90328) * add device_info * use entry_id as identifier + device name * use shorthand attributes * remove model from device info Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- .../components/nextcloud/binary_sensor.py | 2 +- homeassistant/components/nextcloud/entity.py | 20 +++++++++++++------ homeassistant/components/nextcloud/sensor.py | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 0d960bea8e..3cf3cc3ae2 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry( coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - NextcloudBinarySensor(coordinator, name) + NextcloudBinarySensor(coordinator, name, entry) for name in coordinator.data if name in BINARY_SENSORS ] diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index 54976351dd..ed5882cfe7 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -1,23 +1,31 @@ """Base entity for the Nextcloud integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): """Base Nextcloud entity.""" + _attr_has_entity_name = True _attr_icon = "mdi:cloud" - def __init__(self, coordinator: NextcloudDataUpdateCoordinator, item: str) -> None: + def __init__( + self, coordinator: NextcloudDataUpdateCoordinator, item: str, entry: ConfigEntry + ) -> None: """Initialize the Nextcloud sensor.""" super().__init__(coordinator) self.item = item self._attr_name = item - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return f"{self.coordinator.url}#{self.item}" + self._attr_unique_id = f"{coordinator.url}#{item}" + self._attr_device_info = DeviceInfo( + name="Nextcloud", + identifiers={(DOMAIN, entry.entry_id)}, + sw_version=coordinator.data.get("nextcloud_system_version"), + configuration_url=coordinator.url, + ) diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index eb6043e4bc..a5df872e08 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -65,7 +65,7 @@ async def async_setup_entry( coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - NextcloudSensor(coordinator, name) + NextcloudSensor(coordinator, name, entry) for name in coordinator.data if name in SENSORS ] From 45753521010ad844aa6d3177b0371d937e98530d Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Tue, 28 Mar 2023 12:50:57 +0200 Subject: [PATCH 0242/1165] Bump python-bsblan to 0.5.11 (#90377) --- homeassistant/components/bsblan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index f53e395f0c..0e945d13d4 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bsblan", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==0.5.9"] + "requirements": ["python-bsblan==0.5.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index c3dfa944f1..45d6c88ae8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2021,7 +2021,7 @@ pythinkingcleaner==0.0.3 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==0.5.9 +python-bsblan==0.5.11 # homeassistant.components.clementine python-clementine-remote==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6edc309c81..2da989a58a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1468,7 +1468,7 @@ pytankerkoenig==0.0.6 pytautulli==23.1.1 # homeassistant.components.bsblan -python-bsblan==0.5.9 +python-bsblan==0.5.11 # homeassistant.components.ecobee python-ecobee-api==0.2.14 From b207790177ac337745da172857b1dde54569663c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 01:01:41 -1000 Subject: [PATCH 0243/1165] Fix benign typo in discovery flow helper (#90396) --- homeassistant/helpers/discovery_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index bd5ee4942d..586824b449 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -11,7 +11,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.async_ import gather_with_concurrency FLOW_INIT_LIMIT = 2 -DISCOVERY_FLOW_DISPATCHER = "discovery_flow_disptacher" +DISCOVERY_FLOW_DISPATCHER = "discovery_flow_dispatcher" @bind_hass From b6a0ac6f0ad9abcda45d7395e2ae8997398037e5 Mon Sep 17 00:00:00 2001 From: Jan Iven Date: Tue, 28 Mar 2023 13:04:08 +0200 Subject: [PATCH 0244/1165] Fix envoy last_seven_days_energy* state class (#84528) Co-authored-by: Franck Nijhof --- homeassistant/components/enphase_envoy/const.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index cd3235f1be..4a105e5a06 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -33,7 +33,6 @@ SENSORS = ( key="seven_days_production", name="Last Seven Days Energy Production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( @@ -61,7 +60,6 @@ SENSORS = ( key="seven_days_consumption", name="Last Seven Days Energy Consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( From e6c94d78548625f3148af10877b824924e7cbb45 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 28 Mar 2023 13:05:09 +0200 Subject: [PATCH 0245/1165] Remove mysensors notify (#90402) --- .../components/mysensors/__init__.py | 51 ++------- homeassistant/components/mysensors/const.py | 8 +- homeassistant/components/mysensors/notify.py | 100 ------------------ .../components/mysensors/test_config_flow.py | 15 --- tests/components/mysensors/test_notify.py | 95 ----------------- 5 files changed, 8 insertions(+), 261 deletions(-) delete mode 100644 homeassistant/components/mysensors/notify.py delete mode 100644 tests/components/mysensors/test_notify.py diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index d8c3debe7e..129b143062 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging from mysensors import BaseAsyncGateway @@ -12,24 +11,19 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_DEVICES, DOMAIN, - MYSENSORS_DISCOVERY, MYSENSORS_GATEWAYS, MYSENSORS_ON_UNLOAD, - PLATFORMS_WITH_ENTRY_SUPPORT, + PLATFORMS, DevId, DiscoveryInfo, SensorType, ) from .device import MySensorsDevice, get_mysensors_devices from .gateway import finish_setup, gw_stop, setup_gateway -from .helpers import on_unload _LOGGER = logging.getLogger(__name__) @@ -39,14 +33,6 @@ DATA_HASS_CONFIG = "hass_config" CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the MySensors component.""" - # This is needed to set up the notify platform via discovery. - hass.data[DOMAIN] = {DATA_HASS_CONFIG: config} - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an instance of the MySensors integration. @@ -58,33 +44,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Gateway setup failed for %s", entry.data) return False - if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]: - hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {} - hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] = gateway + mysensors_data = hass.data.setdefault(DOMAIN, {}) + if MYSENSORS_GATEWAYS not in mysensors_data: + mysensors_data[MYSENSORS_GATEWAYS] = {} + mysensors_data[MYSENSORS_GATEWAYS][entry.entry_id] = gateway - # Connect notify discovery as that integration doesn't support entry forwarding. - - load_discovery_platform = partial( - async_load_platform, - hass, - Platform.NOTIFY, - DOMAIN, - hass_config=hass.data[DOMAIN][DATA_HASS_CONFIG], - ) - - on_unload( - hass, - entry.entry_id, - async_dispatcher_connect( - hass, - MYSENSORS_DISCOVERY.format(entry.entry_id, Platform.NOTIFY), - load_discovery_platform, - ), - ) - - await hass.config_entries.async_forward_entry_setups( - entry, PLATFORMS_WITH_ENTRY_SUPPORT - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await finish_setup(hass, entry, gateway) return True @@ -95,9 +60,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms( - entry, PLATFORMS_WITH_ENTRY_SUPPORT - ) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not unload_ok: return False diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 5368f65b83..bcdc6f80ab 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -40,7 +40,6 @@ class DiscoveryInfo(TypedDict): """Represent the discovery info type for mysensors platforms.""" devices: list[DevId] - name: str # CONF_NAME is used in the notify base integration. gateway_id: GatewayId @@ -92,8 +91,6 @@ LIGHT_TYPES: dict[SensorType, set[ValueType]] = { "S_RGBW_LIGHT": {"V_RGBW"}, } -NOTIFY_TYPES: dict[SensorType, set[ValueType]] = {"S_INFO": {"V_TEXT"}} - REMOTE_TYPES: dict[SensorType, set[ValueType]] = {"S_IR": {"V_IR_SEND"}} SENSOR_TYPES: dict[SensorType, set[ValueType]] = { @@ -148,7 +145,6 @@ PLATFORM_TYPES: dict[Platform, dict[SensorType, set[ValueType]]] = { Platform.COVER: COVER_TYPES, Platform.DEVICE_TRACKER: DEVICE_TRACKER_TYPES, Platform.LIGHT: LIGHT_TYPES, - Platform.NOTIFY: NOTIFY_TYPES, Platform.REMOTE: REMOTE_TYPES, Platform.SENSOR: SENSOR_TYPES, Platform.SWITCH: SWITCH_TYPES, @@ -167,6 +163,4 @@ for platform, platform_types in PLATFORM_TYPES.items(): for s_type_name in platform_types: TYPE_TO_PLATFORMS[s_type_name].append(platform) -PLATFORMS_WITH_ENTRY_SUPPORT = set(PLATFORM_TYPES.keys()) - { - Platform.NOTIFY, -} +PLATFORMS = tuple(PLATFORM_TYPES) diff --git a/homeassistant/components/mysensors/notify.py b/homeassistant/components/mysensors/notify.py deleted file mode 100644 index 97d4175a6f..0000000000 --- a/homeassistant/components/mysensors/notify.py +++ /dev/null @@ -1,100 +0,0 @@ -"""MySensors notification service.""" -from __future__ import annotations - -from typing import Any, cast - -from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify - -from .. import mysensors -from .const import DOMAIN, DevId, DiscoveryInfo - - -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> BaseNotificationService | None: - """Get the MySensors notification service.""" - if not discovery_info: - return None - - new_devices = mysensors.setup_mysensors_platform( - hass, - Platform.NOTIFY, - cast(DiscoveryInfo, discovery_info), - MySensorsNotificationDevice, - ) - if not new_devices: - return None - return MySensorsNotificationService(hass) - - -class MySensorsNotificationDevice(mysensors.device.MySensorsDevice): - """Represent a MySensors Notification device.""" - - @callback - def _async_update_callback(self) -> None: - """Update the device.""" - self._async_update() - - def send_msg(self, msg: str) -> None: - """Send a message.""" - for sub_msg in [msg[i : i + 25] for i in range(0, len(msg), 25)]: - # Max mysensors payload is 25 bytes. - self.gateway.set_child_value( - self.node_id, self.child_id, self.value_type, sub_msg - ) - - def __repr__(self) -> str: - """Return the representation.""" - return f"" - - -class MySensorsNotificationService(BaseNotificationService): - """Implement a MySensors notification service.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the service.""" - self.devices: dict[ - DevId, MySensorsNotificationDevice - ] = mysensors.get_mysensors_devices( - hass, Platform.NOTIFY - ) # type: ignore[assignment] - self.hass = hass - - async def async_send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to a user.""" - target_devices = kwargs.get(ATTR_TARGET) - devices = [ - device - for device in self.devices.values() - if target_devices is None or device.name in target_devices - ] - - placeholders = { - "alternate_service": "text.set_value", - "deprecated_service": f"notify.{self._service_name}", - "alternate_target": str( - [f"text.{slugify(device.name)}" for device in devices] - ), - } - - async_create_issue( - self.hass, - DOMAIN, - "deprecated_notify_service", - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_service", - translation_placeholders=placeholders, - ) - - for device in devices: - device.send_msg(message) diff --git a/tests/components/mysensors/test_config_flow.py b/tests/components/mysensors/test_config_flow.py index 98a6ae3b23..dc24a48edd 100644 --- a/tests/components/mysensors/test_config_flow.py +++ b/tests/components/mysensors/test_config_flow.py @@ -61,8 +61,6 @@ async def test_config_mqtt(hass: HomeAssistant, mqtt: None) -> None: flow_id = step["flow_id"] with patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -89,7 +87,6 @@ async def test_config_mqtt(hass: HomeAssistant, mqtt: None) -> None: CONF_VERSION: "2.4", CONF_GATEWAY_TYPE: "MQTT", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -121,8 +118,6 @@ async def test_config_serial(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.mysensors.config_flow.try_connect", return_value=True ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -146,7 +141,6 @@ async def test_config_serial(hass: HomeAssistant) -> None: CONF_VERSION: "2.4", CONF_GATEWAY_TYPE: "Serial", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -158,8 +152,6 @@ async def test_config_tcp(hass: HomeAssistant) -> None: with patch( "homeassistant.components.mysensors.config_flow.try_connect", return_value=True ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -183,7 +175,6 @@ async def test_config_tcp(hass: HomeAssistant) -> None: CONF_VERSION: "2.4", CONF_GATEWAY_TYPE: "TCP", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -195,8 +186,6 @@ async def test_fail_to_connect(hass: HomeAssistant) -> None: with patch( "homeassistant.components.mysensors.config_flow.try_connect", return_value=False ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -215,7 +204,6 @@ async def test_fail_to_connect(hass: HomeAssistant) -> None: errors = result["errors"] assert errors assert errors.get("base") == "cannot_connect" - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 @@ -358,8 +346,6 @@ async def test_config_invalid( "homeassistant.components.mysensors.gateway.socket.getaddrinfo", side_effect=OSError, ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -375,7 +361,6 @@ async def test_config_invalid( assert errors assert err_field in errors assert errors[err_field] == err_string - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/mysensors/test_notify.py b/tests/components/mysensors/test_notify.py deleted file mode 100644 index e96b463cc7..0000000000 --- a/tests/components/mysensors/test_notify.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Provide tests for mysensors notify platform.""" -from __future__ import annotations - -from collections.abc import Callable -from unittest.mock import MagicMock, call - -from mysensors.sensor import Sensor - -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_text_type( - hass: HomeAssistant, - text_node: Sensor, - transport_write: MagicMock, - integration: MockConfigEntry, -) -> None: - """Test a text type child.""" - # Test without target. - await hass.services.async_call( - NOTIFY_DOMAIN, "mysensors", {"message": "Hello World"}, blocking=True - ) - - assert transport_write.call_count == 1 - assert transport_write.call_args == call("1;1;1;0;47;Hello World\n") - - # Test with target. - await hass.services.async_call( - NOTIFY_DOMAIN, - "mysensors", - {"message": "Hello", "target": "Text Node 1 1"}, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args == call("1;1;1;0;47;Hello\n") - - transport_write.reset_mock() - - # Test a message longer than 25 characters. - await hass.services.async_call( - NOTIFY_DOMAIN, - "mysensors", - { - "message": "This is a long message that will be split", - "target": "Text Node 1 1", - }, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list == [ - call("1;1;1;0;47;This is a long message th\n"), - call("1;1;1;0;47;at will be split\n"), - ] - - -async def test_text_type_discovery( - hass: HomeAssistant, - text_node: Sensor, - transport_write: MagicMock, - receive_message: Callable[[str], None], -) -> None: - """Test text type discovery.""" - receive_message("1;2;0;0;36;\n") - receive_message("1;2;1;0;47;test\n") - receive_message("1;2;1;0;47;test2\n") # Test that more than one set message works. - await hass.async_block_till_done() - - # Test targeting the discovered child. - await hass.services.async_call( - NOTIFY_DOMAIN, - "mysensors", - {"message": "Hello", "target": "Text Node 1 2"}, - blocking=True, - ) - - assert transport_write.call_count == 1 - assert transport_write.call_args == call("1;2;1;0;47;Hello\n") - - transport_write.reset_mock() - - # Test targeting all notify children. - await hass.services.async_call( - NOTIFY_DOMAIN, "mysensors", {"message": "Hello World"}, blocking=True - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list == [ - call("1;1;1;0;47;Hello World\n"), - call("1;2;1;0;47;Hello World\n"), - ] From de2ca31a71399a6015fa02445913e053a47235eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 01:08:43 -1000 Subject: [PATCH 0246/1165] Remove lru_cache on websocket _state_diff (#90392) --- homeassistant/components/websocket_api/messages.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 0765c6a5b7..ec1ab267a3 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -132,7 +132,6 @@ def _state_diff_event(event: Event) -> dict: return _state_diff(event_old_state, event_new_state) -@lru_cache(maxsize=128) def _state_diff( old_state: State, new_state: State ) -> dict[str, dict[str, dict[str, dict[str, str | list[str]]]]]: From f72bf73b0338a3da4ba9dfe9e9fa185a676fc8a3 Mon Sep 17 00:00:00 2001 From: PatrickGlesner <34370149+PatrickGlesner@users.noreply.github.com> Date: Tue, 28 Mar 2023 13:19:52 +0200 Subject: [PATCH 0247/1165] Fix NMBS IndexError (#90365) --- homeassistant/components/nmbs/sensor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index b9a216875f..8fb227140a 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -162,7 +162,13 @@ class NMBSLiveBoard(SensorEntity): """Set the state equal to the next departure.""" liveboard = self._api_client.get_liveboard(self._station) - if liveboard is None or not liveboard.get("departures"): + if ( + liveboard is None + or liveboard.get("departures") is None + or liveboard.get("departures").get("number") is None + or liveboard.get("departures").get("number") == "0" + or liveboard.get("departures").get("departure") is None + ): return next_departure = liveboard["departures"]["departure"][0] From 08444eeb7608b2233439ce5a90364d113a664e1b Mon Sep 17 00:00:00 2001 From: Geoff Date: Tue, 28 Mar 2023 04:20:20 -0700 Subject: [PATCH 0248/1165] Update transmission up/down speed values (#88528) Co-authored-by: Erik Montnemery Co-authored-by: Franck Nijhof --- homeassistant/components/transmission/sensor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 9147773131..2c7bf24cdf 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -109,18 +109,19 @@ class TransmissionSpeedSensor(TransmissionSensor): """Representation of a Transmission speed sensor.""" _attr_device_class = SensorDeviceClass.DATA_RATE - _attr_native_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND + _attr_native_unit_of_measurement = UnitOfDataRate.BYTES_PER_SECOND + _attr_suggested_display_precision = 2 + _attr_suggested_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND def update(self) -> None: """Get the latest data from Transmission and updates the state.""" if data := self._tm_client.api.data: - mb_spd = ( + b_spd = ( float(data.downloadSpeed) if self._sub_type == "download" else float(data.uploadSpeed) ) - mb_spd = mb_spd / 1024 / 1024 - self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) + self._state = b_spd class TransmissionStatusSensor(TransmissionSensor): From 6e23e00b5a6152bec7ddd6d1fbd851ee552c84aa Mon Sep 17 00:00:00 2001 From: MarkGodwin Date: Tue, 28 Mar 2023 12:25:10 +0100 Subject: [PATCH 0249/1165] TP-Link Omada update entities code review feedback (#89668) --- .../components/tplink_omada/__init__.py | 2 +- .../components/tplink_omada/controller.py | 44 +++--- .../components/tplink_omada/coordinator.py | 9 +- .../components/tplink_omada/manifest.json | 2 +- .../components/tplink_omada/switch.py | 5 +- .../components/tplink_omada/update.py | 132 +++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 102 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 709ad52012..824ea8df42 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -44,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unexpected error connecting to Omada controller: {ex}" ) from ex - site_client = await client.get_site_client(OmadaSite(None, entry.data[CONF_SITE])) + site_client = await client.get_site_client(OmadaSite("", entry.data[CONF_SITE])) controller = OmadaSiteController(hass, site_client) hass.data[DOMAIN][entry.entry_id] = controller diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index b42cb37ff7..508a8b914d 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -1,7 +1,5 @@ """Controller for sharing Omada API coordinators between platforms.""" -from functools import partial - from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails from tplink_omada_client.omadasiteclient import OmadaSiteClient @@ -9,13 +7,28 @@ from homeassistant.core import HomeAssistant from .coordinator import OmadaCoordinator +POLL_SWITCH_PORT = 300 -async def _poll_switch_state( - client: OmadaSiteClient, network_switch: OmadaSwitch -) -> dict[str, OmadaSwitchPortDetails]: - """Poll a switch's current state.""" - ports = await client.get_switch_ports(network_switch) - return {p.port_id: p for p in ports} + +class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): + """Coordinator for getting details about ports on a switch.""" + + def __init__( + self, + hass: HomeAssistant, + omada_client: OmadaSiteClient, + network_switch: OmadaSwitch, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, omada_client, f"{network_switch.name} Ports", POLL_SWITCH_PORT + ) + self._network_switch = network_switch + + async def poll_update(self) -> dict[str, OmadaSwitchPortDetails]: + """Poll a switch's current state.""" + ports = await self.omada_client.get_switch_ports(self._network_switch) + return {p.port_id: p for p in ports} class OmadaSiteController: @@ -26,9 +39,7 @@ class OmadaSiteController: self._hass = hass self._omada_client = omada_client - self._switch_port_coordinators: dict[ - str, OmadaCoordinator[OmadaSwitchPortDetails] - ] = {} + self._switch_port_coordinators: dict[str, OmadaSwitchPortCoordinator] = {} @property def omada_client(self) -> OmadaSiteClient: @@ -37,16 +48,11 @@ class OmadaSiteController: def get_switch_port_coordinator( self, switch: OmadaSwitch - ) -> OmadaCoordinator[OmadaSwitchPortDetails]: + ) -> OmadaSwitchPortCoordinator: """Get coordinator for network port information of a given switch.""" if switch.mac not in self._switch_port_coordinators: - self._switch_port_coordinators[switch.mac] = OmadaCoordinator[ - OmadaSwitchPortDetails - ]( - self._hass, - self._omada_client, - f"{switch.name} Ports", - partial(_poll_switch_state, network_switch=switch), + self._switch_port_coordinators[switch.mac] = OmadaSwitchPortCoordinator( + self._hass, self._omada_client, switch ) return self._switch_port_coordinators[switch.mac] diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index d73461dc78..3ff73501bd 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -1,5 +1,4 @@ """Generic Omada API coordinator.""" -from collections.abc import Awaitable, Callable from datetime import timedelta import logging from typing import Generic, TypeVar @@ -24,7 +23,6 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): hass: HomeAssistant, omada_client: OmadaSiteClient, name: str, - update_func: Callable[[OmadaSiteClient], Awaitable[dict[str, T]]], poll_delay: int = 300, ) -> None: """Initialize my coordinator.""" @@ -35,12 +33,15 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): update_interval=timedelta(seconds=poll_delay), ) self.omada_client = omada_client - self._update_func = update_func async def _async_update_data(self) -> dict[str, T]: """Fetch data from API endpoint.""" try: async with async_timeout.timeout(10): - return await self._update_func(self.omada_client) + return await self.poll_update() except OmadaClientException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + + async def poll_update(self) -> dict[str, T]: + """Poll the current data from the controller.""" + raise NotImplementedError("Update method not implemented") diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index a0fb58b3f6..9d72340776 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.1.3"] + "requirements": ["tplink-omada-client==1.1.4"] } diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index e85b1c181f..830f75b6a9 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -14,8 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .controller import OmadaSiteController -from .coordinator import OmadaCoordinator +from .controller import OmadaSiteController, OmadaSwitchPortCoordinator from .entity import OmadaDeviceEntity POE_SWITCH_ICON = "mdi:ethernet" @@ -68,7 +67,7 @@ class OmadaNetworkSwitchPortPoEControl( def __init__( self, - coordinator: OmadaCoordinator[OmadaSwitchPortDetails], + coordinator: OmadaSwitchPortCoordinator, device: OmadaSwitch, port_id: str, ) -> None: diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 5581f61d82..685ad9c576 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -1,24 +1,26 @@ -"""Support for TPLink Omada device toggle options.""" +"""Support for TPLink Omada device firmware updates.""" from __future__ import annotations -import logging +from datetime import timedelta from typing import Any, NamedTuple from tplink_omada_client.devices import OmadaFirmwareUpdate, OmadaListDevice +from tplink_omada_client.exceptions import OmadaClientException, RequestFailed from tplink_omada_client.omadasiteclient import OmadaSiteClient from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later from .const import DOMAIN from .controller import OmadaSiteController from .coordinator import OmadaCoordinator from .entity import OmadaDeviceEntity -_LOGGER = logging.getLogger(__name__) +POLL_DELAY_IDLE = 6 * 60 * 60 +POLL_DELAY_UPGRADE = 60 class FirmwareUpdateStatus(NamedTuple): @@ -28,24 +30,39 @@ class FirmwareUpdateStatus(NamedTuple): firmware: OmadaFirmwareUpdate | None -async def _get_firmware_updates(client: OmadaSiteClient) -> list[FirmwareUpdateStatus]: - devices = await client.get_devices() - return [ - FirmwareUpdateStatus( - device=d, - firmware=None - if not d.need_upgrade - else await client.get_firmware_details(d), +class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): + """Coordinator for getting details about ports on a switch.""" + + def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + """Initialize my coordinator.""" + super().__init__(hass, omada_client, "Firmware Updates", POLL_DELAY_IDLE) + + async def _get_firmware_updates(self) -> list[FirmwareUpdateStatus]: + devices = await self.omada_client.get_devices() + + updates = [ + FirmwareUpdateStatus( + device=d, + firmware=None + if not d.need_upgrade + else await self.omada_client.get_firmware_details(d), + ) + for d in devices + ] + + # During a firmware upgrade, poll more frequently + self.update_interval = timedelta( + seconds=( + POLL_DELAY_UPGRADE + if any(u.device.fw_download for u in updates) + else POLL_DELAY_IDLE + ) ) - for d in devices - ] + return updates - -async def _poll_firmware_updates( - client: OmadaSiteClient, -) -> dict[str, FirmwareUpdateStatus]: - """Poll the state of Omada Devices firmware update availability.""" - return {d.device.mac: d for d in await _get_firmware_updates(client)} + async def poll_update(self) -> dict[str, FirmwareUpdateStatus]: + """Poll the state of Omada Devices firmware update availability.""" + return {d.device.mac: d for d in await self._get_firmware_updates()} async def async_setup_entry( @@ -59,19 +76,9 @@ async def async_setup_entry( devices = await omada_client.get_devices() - coordinator = OmadaCoordinator[FirmwareUpdateStatus]( - hass, - omada_client, - "Firmware Updates", - _poll_firmware_updates, - poll_delay=6 * 60 * 60, - ) + coordinator = OmadaFirmwareUpdateCoodinator(hass, omada_client) - entities: list = [] - for device in devices: - entities.append(OmadaDeviceUpdate(coordinator, device)) - - async_add_entities(entities) + async_add_entities(OmadaDeviceUpdate(coordinator, device) for device in devices) await coordinator.async_request_refresh() @@ -86,64 +93,57 @@ class OmadaDeviceUpdate( | UpdateEntityFeature.PROGRESS | UpdateEntityFeature.RELEASE_NOTES ) - _firmware_update: OmadaFirmwareUpdate = None + _attr_has_entity_name = True + _attr_name = "Firmware update" def __init__( self, - coordinator: OmadaCoordinator[FirmwareUpdateStatus], + coordinator: OmadaFirmwareUpdateCoodinator, device: OmadaListDevice, ) -> None: """Initialize the update entity.""" super().__init__(coordinator, device) self._mac = device.mac - self._device = device self._omada_client = coordinator.omada_client self._attr_unique_id = f"{device.mac}_firmware" - self._attr_has_entity_name = True - self._attr_name = "Firmware Update" - self._refresh_state() - - def _refresh_state(self) -> None: - if self._firmware_update and self._device.need_upgrade: - self._attr_installed_version = self._firmware_update.current_version - self._attr_latest_version = self._firmware_update.latest_version - else: - self._attr_installed_version = self._device.firmware_version - self._attr_latest_version = self._device.firmware_version - self._attr_in_progress = self._device.fw_download - - if self._attr_in_progress: - # While firmware update is in progress, poll more frequently - async_call_later(self.hass, 60, self._request_refresh) - - async def _request_refresh(self, _now: Any) -> None: - await self.coordinator.async_request_refresh() def release_notes(self) -> str | None: """Get the release notes for the latest update.""" - if self._firmware_update: - return str(self._firmware_update.release_notes) - return "" + status = self.coordinator.data[self._mac] + if status.firmware: + return status.firmware.release_notes + return None async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install a firmware update.""" - if self._firmware_update and ( - version is None or self._firmware_update.latest_version == version - ): - await self._omada_client.start_firmware_upgrade(self._device) + try: + await self._omada_client.start_firmware_upgrade( + self.coordinator.data[self._mac].device + ) + except RequestFailed as ex: + raise HomeAssistantError("Firmware update request rejected") from ex + except OmadaClientException as ex: + raise HomeAssistantError( + "Unable to send Firmware update request. Check the controller is online." + ) from ex + finally: await self.coordinator.async_request_refresh() - else: - _LOGGER.error("Firmware upgrade is not available for %s", self._device.name) @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" status = self.coordinator.data[self._mac] - self._device = status.device - self._firmware_update = status.firmware - self._refresh_state() + + if status.firmware and status.device.need_upgrade: + self._attr_installed_version = status.firmware.current_version + self._attr_latest_version = status.firmware.latest_version + else: + self._attr_installed_version = status.device.firmware_version + self._attr_latest_version = status.device.firmware_version + self._attr_in_progress = status.device.fw_download + self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 45d6c88ae8..1560012227 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2521,7 +2521,7 @@ total_connect_client==2023.2 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.3 +tplink-omada-client==1.1.4 # homeassistant.components.transmission transmission-rpc==3.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2da989a58a..e1235f4734 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1788,7 +1788,7 @@ toonapi==0.2.1 total_connect_client==2023.2 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.3 +tplink-omada-client==1.1.4 # homeassistant.components.transmission transmission-rpc==3.4.0 From 29645d5820a1240155493d4651ae5895e1928e07 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 28 Mar 2023 13:39:32 +0200 Subject: [PATCH 0250/1165] Remove mysensors ir switch (#90403) --- .../components/mysensors/__init__.py | 12 +- homeassistant/components/mysensors/const.py | 1 - homeassistant/components/mysensors/device.py | 4 +- homeassistant/components/mysensors/light.py | 4 +- .../components/mysensors/services.yaml | 18 -- .../components/mysensors/strings.json | 24 --- homeassistant/components/mysensors/switch.py | 158 +----------------- tests/components/mysensors/test_switch.py | 79 --------- 8 files changed, 18 insertions(+), 282 deletions(-) delete mode 100644 homeassistant/components/mysensors/services.yaml diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 129b143062..5b8154e17a 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -1,7 +1,7 @@ """Connect to a MySensors gateway via pymysensors API.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping import logging from mysensors import BaseAsyncGateway @@ -22,7 +22,7 @@ from .const import ( DiscoveryInfo, SensorType, ) -from .device import MySensorsDevice, get_mysensors_devices +from .device import MySensorsEntity, get_mysensors_devices from .gateway import finish_setup, gw_stop, setup_gateway _LOGGER = logging.getLogger(__name__) @@ -99,12 +99,12 @@ def setup_mysensors_platform( hass: HomeAssistant, domain: Platform, # hass platform name discovery_info: DiscoveryInfo, - device_class: type[MySensorsDevice] | dict[SensorType, type[MySensorsDevice]], + device_class: type[MySensorsEntity] | Mapping[SensorType, type[MySensorsEntity]], device_args: ( None | tuple ) = None, # extra arguments that will be given to the entity constructor async_add_entities: Callable | None = None, -) -> list[MySensorsDevice] | None: +) -> list[MySensorsEntity] | None: """Set up a MySensors platform. Sets up a bunch of instances of a single platform that is supported by this @@ -118,10 +118,10 @@ def setup_mysensors_platform( """ if device_args is None: device_args = () - new_devices: list[MySensorsDevice] = [] + new_devices: list[MySensorsEntity] = [] new_dev_ids: list[DevId] = discovery_info[ATTR_DEVICES] for dev_id in new_dev_ids: - devices: dict[DevId, MySensorsDevice] = get_mysensors_devices(hass, domain) + devices: dict[DevId, MySensorsEntity] = get_mysensors_devices(hass, domain) if dev_id in devices: _LOGGER.debug( "Skipping setup of %s for platform %s as it already exists", diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index bcdc6f80ab..7f9326091f 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -132,7 +132,6 @@ SWITCH_TYPES: dict[SensorType, set[ValueType]] = { "S_SOUND": {"V_ARMED"}, "S_VIBRATION": {"V_ARMED"}, "S_MOISTURE": {"V_ARMED"}, - "S_IR": {"V_IR_SEND"}, "S_LOCK": {"V_LOCK_STATUS"}, "S_WATER_QUALITY": {"V_STATUS"}, } diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index de4cbff9b9..d7405dba18 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -202,11 +202,11 @@ class MySensorsDevice(ABC): def get_mysensors_devices( hass: HomeAssistant, domain: Platform -) -> dict[DevId, MySensorsDevice]: +) -> dict[DevId, MySensorsEntity]: """Return MySensors devices for a hass platform name.""" if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - devices: dict[DevId, MySensorsDevice] = hass.data[DOMAIN][ + devices: dict[DevId, MySensorsEntity] = hass.data[DOMAIN][ MYSENSORS_PLATFORM_DEVICES.format(domain) ] return devices diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index e83002ed87..68f8bb566f 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -19,7 +19,7 @@ from homeassistant.util.color import rgb_hex_to_rgb_list from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsDevice +from .device import MySensorsEntity from .helpers import on_unload @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" - device_class_map: dict[SensorType, type[MySensorsDevice]] = { + device_class_map: dict[SensorType, type[MySensorsEntity]] = { "S_DIMMER": MySensorsLightDimmer, "S_RGB_LIGHT": MySensorsLightRGB, "S_RGBW_LIGHT": MySensorsLightRGBW, diff --git a/homeassistant/components/mysensors/services.yaml b/homeassistant/components/mysensors/services.yaml deleted file mode 100644 index 7293a676a7..0000000000 --- a/homeassistant/components/mysensors/services.yaml +++ /dev/null @@ -1,18 +0,0 @@ -send_ir_code: - name: Send IR code - description: Set an IR code as a state attribute for a MySensors IR device switch and turn the switch on. - fields: - entity_id: - name: Entity - description: Name of entity that should have the IR code set and be turned on. Platform dependent. - selector: - entity: - integration: mysensors - domain: switch - V_IR_SEND: - name: IR send - description: IR code to send. - required: true - example: "0xC284" - selector: - text: diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index c192db7549..dc5dc76c7a 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -83,29 +83,5 @@ "port_out_of_range": "Port number must be at least 1 and at most 65535", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "issues": { - "deprecated_entity": { - "title": "The {deprecated_entity} entity will be removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The {deprecated_entity} entity will be removed", - "description": "Update any automations or scripts that use this entity in service calls using the `{deprecated_service}` service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." - } - } - } - }, - "deprecated_service": { - "title": "The {deprecated_service} service will be removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The {deprecated_service} service will be removed", - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." - } - } - } - } } } diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index e5b0968785..6067a98af0 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -3,34 +3,18 @@ from __future__ import annotations from typing import Any -import voluptuous as vol - from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .. import mysensors -from .const import ( - DOMAIN as MYSENSORS_DOMAIN, - MYSENSORS_DISCOVERY, - SERVICE_SEND_IR_CODE, - DiscoveryInfo, - SensorType, -) -from .device import MySensorsDevice +from . import setup_mysensors_platform +from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType +from .device import MySensorsEntity from .helpers import on_unload -ATTR_IR_CODE = "V_IR_SEND" - -SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema( - {vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_IR_CODE): cv.string} -) - async def async_setup_entry( hass: HomeAssistant, @@ -38,13 +22,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" - device_class_map: dict[SensorType, type[MySensorsDevice]] = { + device_class_map: dict[SensorType, type[MySensorsSwitch]] = { "S_DOOR": MySensorsSwitch, "S_MOTION": MySensorsSwitch, "S_SMOKE": MySensorsSwitch, "S_LIGHT": MySensorsSwitch, "S_LOCK": MySensorsSwitch, - "S_IR": MySensorsIRSwitch, "S_BINARY": MySensorsSwitch, "S_SPRINKLER": MySensorsSwitch, "S_WATER_LEAK": MySensorsSwitch, @@ -56,7 +39,7 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors switch.""" - mysensors.setup_mysensors_platform( + setup_mysensors_platform( hass, Platform.SWITCH, discovery_info, @@ -64,37 +47,6 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - async def async_send_ir_code_service(service: ServiceCall) -> None: - """Set IR code as device state attribute.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) - ir_code = service.data.get(ATTR_IR_CODE) - devices = mysensors.get_mysensors_devices(hass, Platform.SWITCH) - - if entity_ids: - _devices = [ - device - for device in devices.values() - if isinstance(device, MySensorsIRSwitch) - and device.entity_id in entity_ids - ] - else: - _devices = [ - device - for device in devices.values() - if isinstance(device, MySensorsIRSwitch) - ] - - kwargs = {ATTR_IR_CODE: ir_code} - for device in _devices: - await device.async_turn_on(**kwargs) - - hass.services.async_register( - MYSENSORS_DOMAIN, - SERVICE_SEND_IR_CODE, - async_send_ir_code_service, - schema=SEND_IR_CODE_SERVICE_SCHEMA, - ) - on_unload( hass, config_entry.entry_id, @@ -106,7 +58,7 @@ async def async_setup_entry( ) -class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): +class MySensorsSwitch(MySensorsEntity, SwitchEntity): """Representation of the value of a MySensors Switch child node.""" @property @@ -133,97 +85,3 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): # Optimistically assume that switch has changed state self._values[self.value_type] = STATE_OFF self.async_write_ha_state() - - -class MySensorsIRSwitch(MySensorsSwitch): - """IR switch child class to MySensorsSwitch.""" - - def __init__(self, *args: Any) -> None: - """Set up instance attributes.""" - super().__init__(*args) - self._ir_code: str | None = None - - @property - def is_on(self) -> bool: - """Return True if switch is on.""" - set_req = self.gateway.const.SetReq - return self._values.get(set_req.V_LIGHT) == STATE_ON - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the IR switch on.""" - set_req = self.gateway.const.SetReq - placeholders = { - "deprecated_entity": self.entity_id, - "alternate_target": f"remote.{split_entity_id(self.entity_id)[1]}", - } - - if ATTR_IR_CODE in kwargs: - self._ir_code = kwargs[ATTR_IR_CODE] - placeholders[ - "deprecated_service" - ] = f"{MYSENSORS_DOMAIN}.{SERVICE_SEND_IR_CODE}" - placeholders["alternate_service"] = "remote.send_command" - else: - placeholders["deprecated_service"] = "switch.turn_on" - placeholders["alternate_service"] = "remote.turn_on" - - async_create_issue( - self.hass, - MYSENSORS_DOMAIN, - ( - "deprecated_ir_switch_entity_" - f"{self.entity_id}_{placeholders['deprecated_service']}" - ), - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders=placeholders, - ) - self.gateway.set_child_value( - self.node_id, self.child_id, self.value_type, self._ir_code - ) - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 - ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[self.value_type] = self._ir_code - self._values[set_req.V_LIGHT] = STATE_ON - self.async_write_ha_state() - # Turn off switch after switch was turned on - await self.async_turn_off() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the IR switch off.""" - async_create_issue( - self.hass, - MYSENSORS_DOMAIN, - f"deprecated_ir_switch_entity_{self.entity_id}_switch.turn_off", - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders={ - "deprecated_entity": self.entity_id, - "deprecated_service": "switch.turn_off", - "alternate_service": "remote.turn_off", - "alternate_target": f"remote.{split_entity_id(self.entity_id)[1]}", - }, - ) - set_req = self.gateway.const.SetReq - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_LIGHT, 0, ack=1 - ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[set_req.V_LIGHT] = STATE_OFF - self.async_write_ha_state() - - @callback - def _async_update(self) -> None: - """Update the controller with the latest value from a sensor.""" - super()._async_update() - self._ir_code = self._values.get(self.value_type) diff --git a/tests/components/mysensors/test_switch.py b/tests/components/mysensors/test_switch.py index b77d540d54..59cea514d7 100644 --- a/tests/components/mysensors/test_switch.py +++ b/tests/components/mysensors/test_switch.py @@ -6,7 +6,6 @@ from unittest.mock import MagicMock, call from mysensors.sensor import Sensor -from homeassistant.components.mysensors.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.core import HomeAssistant @@ -62,81 +61,3 @@ async def test_relay_node( assert state assert state.state == "off" - - -async def test_ir_transceiver( - hass: HomeAssistant, - ir_transceiver: Sensor, - receive_message: Callable[[str], None], - transport_write: MagicMock, -) -> None: - """Test an ir transceiver.""" - entity_id = "switch.ir_transceiver_1_1" - - state = hass.states.get(entity_id) - - assert state - assert state.state == "off" - - await hass.services.async_call( - SWITCH_DOMAIN, - "turn_on", - {"entity_id": entity_id}, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list[0] == call("1;1;1;0;32;test_code\n") - assert transport_write.call_args_list[1] == call("1;1;1;1;2;1\n") - - receive_message("1;1;1;0;2;1\n") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - - assert state - assert state.state == "on" - assert state.attributes["V_IR_SEND"] == "test_code" - - transport_write.reset_mock() - - await hass.services.async_call( - SWITCH_DOMAIN, - "turn_off", - {"entity_id": entity_id}, - blocking=True, - ) - - assert transport_write.call_count == 1 - assert transport_write.call_args == call("1;1;1;1;2;0\n") - - receive_message("1;1;1;0;2;0\n") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - - assert state - assert state.state == "off" - - transport_write.reset_mock() - - await hass.services.async_call( - DOMAIN, - "send_ir_code", - {"entity_id": entity_id, "V_IR_SEND": "new_code"}, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list[0] == call("1;1;1;0;32;new_code\n") - assert transport_write.call_args_list[1] == call("1;1;1;1;2;1\n") - - receive_message("1;1;1;0;32;new_code\n") - receive_message("1;1;1;0;2;1\n") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - - assert state - assert state.state == "on" - assert state.attributes["V_IR_SEND"] == "new_code" From 7b18df321b094d9f450a66553367c3648ddce245 Mon Sep 17 00:00:00 2001 From: Ryan Fleming Date: Tue, 28 Mar 2023 08:00:35 -0400 Subject: [PATCH 0251/1165] Have octoprint camera respect verify_ssl configuration (#90384) --- homeassistant/components/octoprint/camera.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index 653c15f184..9c3049ff87 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -5,6 +5,7 @@ from pyoctoprintapi import OctoprintClient, WebcamSettings from homeassistant.components.mjpeg.camera import MjpegCamera from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -28,6 +29,7 @@ async def async_setup_entry( assert device_id is not None camera_info = await client.get_webcam_info() + verify_ssl = config_entry.data[CONF_VERIFY_SSL] if not camera_info or not camera_info.enabled: return @@ -38,6 +40,7 @@ async def async_setup_entry( camera_info, coordinator.device_info, device_id, + verify_ssl, ) ] ) @@ -47,7 +50,11 @@ class OctoprintCamera(MjpegCamera): """Representation of an OctoPrint Camera Stream.""" def __init__( - self, camera_settings: WebcamSettings, device_info: DeviceInfo, device_id: str + self, + camera_settings: WebcamSettings, + device_info: DeviceInfo, + device_id: str, + verify_ssl: bool, ) -> None: """Initialize as a subclass of MjpegCamera.""" super().__init__( @@ -56,4 +63,5 @@ class OctoprintCamera(MjpegCamera): name="OctoPrint Camera", still_image_url=camera_settings.external_snapshot_url, unique_id=device_id, + verify_ssl=verify_ssl, ) From 0eb409cff1a078b36abbdf47005de90a22560167 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 28 Mar 2023 14:01:31 +0200 Subject: [PATCH 0252/1165] Add support for select entities in velbus (#87568) * Add support for select entities in velbus * Implement comments * EntityCategory is now in homeassistant.const * more comments --- .coveragerc | 1 + homeassistant/components/velbus/__init__.py | 1 + homeassistant/components/velbus/select.py | 47 +++++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 homeassistant/components/velbus/select.py diff --git a/.coveragerc b/.coveragerc index dfc13304b1..5b5096ee58 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1368,6 +1368,7 @@ omit = homeassistant/components/velbus/entity.py homeassistant/components/velbus/light.py homeassistant/components/velbus/sensor.py + homeassistant/components/velbus/select.py homeassistant/components/velbus/switch.py homeassistant/components/velux/__init__.py homeassistant/components/velux/cover.py diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index a51cef0a56..554b16877c 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -34,6 +34,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.LIGHT, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py new file mode 100644 index 0000000000..af79b5d127 --- /dev/null +++ b/homeassistant/components/velbus/select.py @@ -0,0 +1,47 @@ +"""Support for Velbus select.""" +from velbusaio.channels import SelectedProgram + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import VelbusEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Velbus select based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] + cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + async_add_entities(VelbusSelect(channel) for channel in cntrl.get_all("select")) + + +class VelbusSelect(VelbusEntity, SelectEntity): + """Representation of a select option for velbus.""" + + _channel: SelectedProgram + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + channel: SelectedProgram, + ) -> None: + """Initialize a select Velbus entity.""" + super().__init__(channel) + self._attr_options = self._channel.get_options() + self._attr_unique_id = f"{self._attr_unique_id}-program_select" + + async def async_select_option(self, option: str) -> None: + """Update the program on the module.""" + await self._channel.set_selected_program(option) + + @property + def current_option(self) -> str: + """Return the selected option.""" + return self._channel.get_selected_program() From cc404cfe770cf15908423384203ca6d6444de049 Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Tue, 28 Mar 2023 13:24:19 +0100 Subject: [PATCH 0253/1165] Refactor Tado to use entity descriptions and new naming style (#75750) * Refactor Tado to use entity descriptions and new naming style * minor fixes * typing --- .../components/tado/binary_sensor.py | 242 +++++++------- homeassistant/components/tado/climate.py | 16 +- homeassistant/components/tado/entity.py | 2 + homeassistant/components/tado/sensor.py | 312 ++++++++---------- 4 files changed, 274 insertions(+), 298 deletions(-) diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 7f009c278f..24d62d7602 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -1,14 +1,21 @@ """Support for Tado sensors for each zone.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import Any 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 homeassistant.helpers.typing import StateType from .const import ( DATA, @@ -24,31 +31,99 @@ from .entity import TadoDeviceEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) + +@dataclass +class TadoBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + state_fn: Callable[[Any], bool] + + +@dataclass +class TadoBinarySensorEntityDescription( + BinarySensorEntityDescription, TadoBinarySensorEntityDescriptionMixin +): + """Describes Tado binary sensor entity.""" + + attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None + + +BATTERY_STATE_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="battery state", + name="Battery state", + state_fn=lambda data: data["batteryState"] == "LOW", + device_class=BinarySensorDeviceClass.BATTERY, +) +CONNECTION_STATE_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="connection state", + name="Connection state", + state_fn=lambda data: data.get("connectionState", {}).get("value", False), + device_class=BinarySensorDeviceClass.CONNECTIVITY, +) +POWER_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="power", + name="Power", + state_fn=lambda data: data.power == "ON", + device_class=BinarySensorDeviceClass.POWER, +) +LINK_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="link", + name="Link", + state_fn=lambda data: data.link == "ONLINE", + device_class=BinarySensorDeviceClass.CONNECTIVITY, +) +OVERLAY_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="overlay", + name="Overlay", + state_fn=lambda data: data.overlay_active, + attributes_fn=lambda data: {"termination": data.overlay_termination_type} + if data.overlay_active + else {}, + device_class=BinarySensorDeviceClass.POWER, +) +OPEN_WINDOW_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="open window", + name="Open window", + state_fn=lambda data: bool(data.open_window or data.open_window_detected), + attributes_fn=lambda data: data.open_window_attr, + device_class=BinarySensorDeviceClass.WINDOW, +) +EARLY_START_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="early start", + name="Early start", + state_fn=lambda data: data.preparation, + device_class=BinarySensorDeviceClass.POWER, +) + DEVICE_SENSORS = { TYPE_BATTERY: [ - "battery state", - "connection state", + BATTERY_STATE_ENTITY_DESCRIPTION, + CONNECTION_STATE_ENTITY_DESCRIPTION, ], TYPE_POWER: [ - "connection state", + CONNECTION_STATE_ENTITY_DESCRIPTION, ], } ZONE_SENSORS = { TYPE_HEATING: [ - "power", - "link", - "overlay", - "early start", - "open window", + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, + OPEN_WINDOW_ENTITY_DESCRIPTION, + EARLY_START_ENTITY_DESCRIPTION, ], TYPE_AIR_CONDITIONING: [ - "power", - "link", - "overlay", - "open window", + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, + OPEN_WINDOW_ENTITY_DESCRIPTION, + ], + TYPE_HOT_WATER: [ + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, ], - TYPE_HOT_WATER: ["power", "link", "overlay"], } @@ -71,8 +146,8 @@ async def async_setup_entry( entities.extend( [ - TadoDeviceBinarySensor(tado, device, variable) - for variable in DEVICE_SENSORS[device_type] + TadoDeviceBinarySensor(tado, device, entity_description) + for entity_description in DEVICE_SENSORS[device_type] ] ) @@ -85,8 +160,8 @@ async def async_setup_entry( entities.extend( [ - TadoZoneBinarySensor(tado, zone["name"], zone["id"], variable) - for variable in ZONE_SENSORS[zone_type] + TadoZoneBinarySensor(tado, zone["name"], zone["id"], entity_description) + for entity_description in ZONE_SENSORS[zone_type] ] ) @@ -96,16 +171,21 @@ async def async_setup_entry( class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, device_info, device_variable): + entity_description: TadoBinarySensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, tado, device_info, entity_description: TadoBinarySensorEntityDescription + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(device_info) - self.device_variable = device_variable - - self._unique_id = f"{device_variable} {self.device_id} {tado.home_id}" - - self._state = None + self._attr_unique_id = ( + f"{entity_description.key} {self.device_id} {tado.home_id}" + ) async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -121,30 +201,6 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): ) self._async_update_device_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.device_name} {self.device_variable}" - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of this sensor.""" - if self.device_variable == "battery state": - return BinarySensorDeviceClass.BATTERY - if self.device_variable == "connection state": - return BinarySensorDeviceClass.CONNECTIVITY - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -159,29 +215,33 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): except KeyError: return - if self.device_variable == "battery state": - self._state = self._device_info["batteryState"] == "LOW" - elif self.device_variable == "connection state": - self._state = self._device_info.get("connectionState", {}).get( - "value", False + self._attr_is_on = self.entity_description.state_fn(self._device_info) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + self._device_info ) class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, zone_name, zone_id, zone_variable): + entity_description: TadoBinarySensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + tado, + zone_name, + zone_id, + entity_description: TadoBinarySensorEntityDescription, + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(zone_name, tado.home_id, zone_id) - self.zone_variable = zone_variable - - self._unique_id = f"{zone_variable} {zone_id} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_zone_data = None + self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -197,41 +257,6 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): ) self._async_update_zone_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.zone_name} {self.zone_variable}" - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of this sensor.""" - if self.zone_variable == "early start": - return BinarySensorDeviceClass.POWER - if self.zone_variable == "link": - return BinarySensorDeviceClass.CONNECTIVITY - if self.zone_variable == "open window": - return BinarySensorDeviceClass.WINDOW - if self.zone_variable == "overlay": - return BinarySensorDeviceClass.POWER - if self.zone_variable == "power": - return BinarySensorDeviceClass.POWER - return None - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - @callback def _async_update_callback(self): """Update and write state.""" @@ -242,29 +267,12 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): def _async_update_zone_data(self): """Handle update callbacks.""" try: - self._tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self._tado.data["zone"][self.zone_id] except KeyError: return - if self.zone_variable == "power": - self._state = self._tado_zone_data.power == "ON" - - elif self.zone_variable == "link": - self._state = self._tado_zone_data.link == "ONLINE" - - elif self.zone_variable == "overlay": - self._state = self._tado_zone_data.overlay_active - if self._tado_zone_data.overlay_active: - self._state_attributes = { - "termination": self._tado_zone_data.overlay_termination_type - } - - elif self.zone_variable == "early start": - self._state = self._tado_zone_data.preparation - - elif self.zone_variable == "open window": - self._state = bool( - self._tado_zone_data.open_window - or self._tado_zone_data.open_window_detected + self._attr_is_on = self.entity_description.state_fn(tado_zone_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_zone_data ) - self._state_attributes = self._tado_zone_data.open_window_attr diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index a72451b002..cab3c42184 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -240,7 +240,11 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self.zone_id = zone_id self.zone_type = zone_type - self._unique_id = f"{zone_type} {zone_id} {tado.home_id}" + + self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}" + self._attr_name = zone_name + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] @@ -288,16 +292,6 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) ) - @property - def name(self): - """Return the name of the entity.""" - return self.zone_name - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - @property def current_humidity(self): """Return the current humidity.""" diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index 11de7ceb31..c825bafc4b 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -33,6 +33,8 @@ class TadoDeviceEntity(Entity): class TadoHomeEntity(Entity): """Base implementation for Tado home.""" + _attr_should_poll = False + def __init__(self, tado): """Initialize a Tado home.""" super().__init__() diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 4289813494..d218e9ca93 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -1,9 +1,15 @@ """Support for Tado sensors for each zone.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -11,6 +17,7 @@ from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ( CONDITIONS_MAP, @@ -25,26 +32,108 @@ from .entity import TadoHomeEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) -HOME_SENSORS = { - "outdoor temperature", - "solar percentage", - "weather condition", -} + +@dataclass +class TadoSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + state_fn: Callable[[Any], StateType] + + +@dataclass +class TadoSensorEntityDescription( + SensorEntityDescription, TadoSensorEntityDescriptionMixin +): + """Describes Tado sensor entity.""" + + attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None + + +HOME_SENSORS = [ + TadoSensorEntityDescription( + key="outdoor temperature", + name="Outdoor temperature", + state_fn=lambda data: data["outsideTemperature"]["celsius"], + attributes_fn=lambda data: { + "time": data["outsideTemperature"]["timestamp"], + }, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TadoSensorEntityDescription( + key="solar percentage", + name="Solar percentage", + state_fn=lambda data: data["solarIntensity"]["percentage"], + attributes_fn=lambda data: { + "time": data["solarIntensity"]["timestamp"], + }, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + TadoSensorEntityDescription( + key="weather condition", + name="Weather condition", + state_fn=lambda data: format_condition(data["weatherState"]["value"]), + attributes_fn=lambda data: {"time": data["weatherState"]["timestamp"]}, + ), +] + +TEMPERATURE_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="temperature", + name="Temperature", + state_fn=lambda data: data.current_temp, + attributes_fn=lambda data: { + "time": data.current_temp_timestamp, + "setting": 0, # setting is used in climate device + }, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, +) +HUMIDITY_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="humidity", + name="Humidity", + state_fn=lambda data: data.current_humidity, + attributes_fn=lambda data: {"time": data.current_humidity_timestamp}, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, +) +TADO_MODE_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="tado mode", + name="Tado mode", + state_fn=lambda data: data.tado_mode, +) +HEATING_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="heating", + name="Heating", + state_fn=lambda data: data.heating_power_percentage, + attributes_fn=lambda data: {"time": data.heating_power_timestamp}, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, +) +AC_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="ac", + name="AC", + state_fn=lambda data: data.ac_power, + attributes_fn=lambda data: {"time": data.ac_power_timestamp}, +) ZONE_SENSORS = { TYPE_HEATING: [ - "temperature", - "humidity", - "heating", - "tado mode", + TEMPERATURE_ENTITY_DESCRIPTION, + HUMIDITY_ENTITY_DESCRIPTION, + TADO_MODE_ENTITY_DESCRIPTION, + HEATING_ENTITY_DESCRIPTION, ], TYPE_AIR_CONDITIONING: [ - "temperature", - "humidity", - "ac", - "tado mode", + TEMPERATURE_ENTITY_DESCRIPTION, + HUMIDITY_ENTITY_DESCRIPTION, + TADO_MODE_ENTITY_DESCRIPTION, + AC_ENTITY_DESCRIPTION, ], - TYPE_HOT_WATER: ["tado mode"], + TYPE_HOT_WATER: [TADO_MODE_ENTITY_DESCRIPTION], } @@ -66,7 +155,12 @@ async def async_setup_entry( entities: list[SensorEntity] = [] # Create home sensors - entities.extend([TadoHomeSensor(tado, variable) for variable in HOME_SENSORS]) + entities.extend( + [ + TadoHomeSensor(tado, entity_description) + for entity_description in HOME_SENSORS + ] + ) # Create zone sensors for zone in zones: @@ -77,8 +171,8 @@ async def async_setup_entry( entities.extend( [ - TadoZoneSensor(tado, zone["name"], zone["id"], variable) - for variable in ZONE_SENSORS[zone_type] + TadoZoneSensor(tado, zone["name"], zone["id"], entity_description) + for entity_description in ZONE_SENSORS[zone_type] ] ) @@ -88,18 +182,17 @@ async def async_setup_entry( class TadoHomeSensor(TadoHomeEntity, SensorEntity): """Representation of a Tado Sensor.""" - def __init__(self, tado, home_variable): + entity_description: TadoSensorEntityDescription + + _attr_has_entity_name = True + + def __init__(self, tado, entity_description: TadoSensorEntityDescription) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description super().__init__(tado) self._tado = tado - self.home_variable = home_variable - - self._unique_id = f"{home_variable} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_weather_data = self._tado.data["weather"] + self._attr_unique_id = f"{entity_description.key} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -115,50 +208,6 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): ) self._async_update_home_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._tado.home_name} {self.home_variable}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - if self.home_variable in ["temperature", "outdoor temperature"]: - return UnitOfTemperature.CELSIUS - if self.home_variable == "solar percentage": - return PERCENTAGE - if self.home_variable == "weather condition": - return None - - @property - def device_class(self): - """Return the device class.""" - if self.home_variable == "outdoor temperature": - return SensorDeviceClass.TEMPERATURE - return None - - @property - def state_class(self): - """Return the state class.""" - if self.home_variable in ["outdoor temperature", "solar percentage"]: - return SensorStateClass.MEASUREMENT - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -169,46 +218,37 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): def _async_update_home_data(self): """Handle update callbacks.""" try: - self._tado_weather_data = self._tado.data["weather"] + tado_weather_data = self._tado.data["weather"] except KeyError: return - if self.home_variable == "outdoor temperature": - self._state = self._tado_weather_data["outsideTemperature"]["celsius"] - self._state_attributes = { - "time": self._tado_weather_data["outsideTemperature"]["timestamp"], - } - - elif self.home_variable == "solar percentage": - self._state = self._tado_weather_data["solarIntensity"]["percentage"] - self._state_attributes = { - "time": self._tado_weather_data["solarIntensity"]["timestamp"], - } - - elif self.home_variable == "weather condition": - self._state = format_condition( - self._tado_weather_data["weatherState"]["value"] + self._attr_native_value = self.entity_description.state_fn(tado_weather_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_weather_data ) - self._state_attributes = { - "time": self._tado_weather_data["weatherState"]["timestamp"] - } class TadoZoneSensor(TadoZoneEntity, SensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, zone_name, zone_id, zone_variable): + entity_description: TadoSensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + tado, + zone_name, + zone_id, + entity_description: TadoSensorEntityDescription, + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(zone_name, tado.home_id, zone_id) - self.zone_variable = zone_variable - - self._unique_id = f"{zone_variable} {zone_id} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_zone_data = None + self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -224,54 +264,6 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): ) self._async_update_zone_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.zone_name} {self.zone_variable}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - if self.zone_variable == "temperature": - return UnitOfTemperature.CELSIUS - if self.zone_variable == "humidity": - return PERCENTAGE - if self.zone_variable == "heating": - return PERCENTAGE - if self.zone_variable == "ac": - return None - - @property - def device_class(self): - """Return the device class.""" - if self.zone_variable == "humidity": - return SensorDeviceClass.HUMIDITY - if self.zone_variable == "temperature": - return SensorDeviceClass.TEMPERATURE - return None - - @property - def state_class(self): - """Return the state class.""" - if self.zone_variable in ["heating", "humidity", "temperature"]: - return SensorStateClass.MEASUREMENT - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -282,32 +274,12 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def _async_update_zone_data(self): """Handle update callbacks.""" try: - self._tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self._tado.data["zone"][self.zone_id] except KeyError: return - if self.zone_variable == "temperature": - self._state = self._tado_zone_data.current_temp - self._state_attributes = { - "time": self._tado_zone_data.current_temp_timestamp, - "setting": 0, # setting is used in climate device - } - - elif self.zone_variable == "humidity": - self._state = self._tado_zone_data.current_humidity - self._state_attributes = { - "time": self._tado_zone_data.current_humidity_timestamp - } - - elif self.zone_variable == "heating": - self._state = self._tado_zone_data.heating_power_percentage - self._state_attributes = { - "time": self._tado_zone_data.heating_power_timestamp - } - - elif self.zone_variable == "ac": - self._state = self._tado_zone_data.ac_power - self._state_attributes = {"time": self._tado_zone_data.ac_power_timestamp} - - elif self.zone_variable == "tado mode": - self._state = self._tado_zone_data.tado_mode + self._attr_native_value = self.entity_description.state_fn(tado_zone_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_zone_data + ) From 8e7013b079761c8ef42a3c47945985cf68016dd0 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 28 Mar 2023 08:34:57 -0400 Subject: [PATCH 0254/1165] Add HTML support for Google Mail messages (#87201) --- homeassistant/components/google_mail/notify.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_mail/notify.py b/homeassistant/components/google_mail/notify.py index eba38c3249..974b2e4e4b 100644 --- a/homeassistant/components/google_mail/notify.py +++ b/homeassistant/components/google_mail/notify.py @@ -2,7 +2,7 @@ from __future__ import annotations import base64 -from email.message import EmailMessage +from email.mime.text import MIMEText from typing import Any from googleapiclient.http import HttpRequest @@ -43,8 +43,7 @@ class GMailNotificationService(BaseNotificationService): data: dict[str, Any] = kwargs.get(ATTR_DATA) or {} title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - email = EmailMessage() - email.set_content(message) + email = MIMEText(message, "html") if to_addrs := kwargs.get(ATTR_TARGET): email["To"] = ", ".join(to_addrs) email["From"] = data.get(ATTR_FROM, ATTR_ME) From d228df6d818f663b75a2efd9696d7507789b8a3e Mon Sep 17 00:00:00 2001 From: "Erik J. Olson" Date: Tue, 28 Mar 2023 07:56:10 -0500 Subject: [PATCH 0255/1165] Fix Notify Group payload data mis-merge (#90253) Co-authored-by: Erik Montnemery --- homeassistant/components/group/notify.py | 24 ++++++++++----------- tests/components/group/test_notify.py | 27 +++++++++++++++++++++--- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 7e8ce92364..378a785234 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -32,18 +32,16 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def update(input_dict: dict[str, Any], update_source: dict[str, Any]) -> dict[str, Any]: - """Deep update a dictionary. - - Async friendly. - """ - for key, val in update_source.items(): +def add_defaults( + input_data: dict[str, Any], default_data: dict[str, Any] +) -> dict[str, Any]: + """Deep update a dictionary with default values.""" + for key, val in default_data.items(): if isinstance(val, Mapping): - recurse = update(input_dict.get(key, {}), val) # type: ignore[arg-type] - input_dict[key] = recurse - else: - input_dict[key] = update_source[key] - return input_dict + input_data[key] = add_defaults(input_data.get(key, {}), val) # type: ignore[arg-type] + elif key not in input_data: + input_data[key] = val + return input_data async def async_get_service( @@ -71,8 +69,8 @@ class GroupNotifyPlatform(BaseNotificationService): tasks: list[asyncio.Task[bool | None]] = [] for entity in self.entities: sending_payload = deepcopy(payload.copy()) - if (data := entity.get(ATTR_DATA)) is not None: - update(sending_payload, data) + if (default_data := entity.get(ATTR_DATA)) is not None: + add_defaults(sending_payload, default_data) tasks.append( asyncio.create_task( self.hass.services.async_call( diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index 6e4f9b5039..77569c80f0 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -54,14 +54,14 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None: "service": "demo2", "data": { "target": "unnamed device", - "data": {"test": "message"}, + "data": {"test": "message", "default": "default"}, }, }, ] }, ) - """Test sending a message with to a notify group.""" + """Test sending a message to a notify group.""" await service.async_send_message( "Hello", title="Test notification", data={"hello": "world"} ) @@ -77,7 +77,28 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None: assert service2.send_message.mock_calls[0][2] == { "target": ["unnamed device"], "title": "Test notification", - "data": {"hello": "world", "test": "message"}, + "data": {"hello": "world", "test": "message", "default": "default"}, + } + + """Test sending a message which overrides service defaults to a notify group.""" + await service.async_send_message( + "Hello", + title="Test notification", + data={"hello": "world", "default": "override"}, + ) + + 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"}, } From 091932c3acd984465d4f2a474b8943e649c8fbd1 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Tue, 28 Mar 2023 12:59:03 +0000 Subject: [PATCH 0256/1165] Improve airq test coverage (#90192) * Add a missing test for aborting with "already_configured" Test that config_flow aborts with "already_configured" when the integration has already been configured * Don't copy test data Since #90232 is merged, it is no longer needed * Split the initialisation into two steps, as it should be --- tests/components/airq/test_config_flow.py | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index af71dc813e..252c12f80f 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -11,6 +11,8 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + pytestmark = pytest.mark.usefixtures("mock_setup_entry") TEST_USER_DATA = { @@ -91,3 +93,25 @@ async def test_form_invalid_input(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "invalid_input"} + + +async def test_duplicate_error(hass: HomeAssistant) -> None: + """Test that errors are shown when duplicates are added.""" + MockConfigEntry( + data=TEST_USER_DATA, + domain=DOMAIN, + unique_id=TEST_DEVICE_INFO["id"], + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("aioairq.AirQ.validate"), patch( + "aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" From cdefc48fcdbaa70674a91f77ed5a0cb4112afe50 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 28 Mar 2023 07:07:09 -0600 Subject: [PATCH 0257/1165] Add panel brightness control for Litter-Robot 4 (#86269) * Add panel brightness control for Litter-Robot 4 * Use translation_key * Fix test --- .../components/litterrobot/manifest.json | 2 +- .../components/litterrobot/select.py | 94 ++++++++++++------- .../components/litterrobot/strings.json | 9 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/litterrobot/test_select.py | 41 +++++++- 6 files changed, 111 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index e635e80a6e..0b162ee2e5 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.1.1"] + "requirements": ["pylitterbot==2023.1.2"] } diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index bc1613f1c2..feac85ecac 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -3,10 +3,10 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass -import itertools from typing import Any, Generic, TypeVar -from pylitterbot import FeederRobot, LitterRobot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot +from pylitterbot.robot.litterrobot4 import BrightnessLevel from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -18,14 +18,21 @@ from .const import DOMAIN from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub -_CastTypeT = TypeVar("_CastTypeT", int, float) +_CastTypeT = TypeVar("_CastTypeT", int, float, str) + +BRIGHTNESS_LEVEL_ICON_MAP: dict[BrightnessLevel | None, str] = { + BrightnessLevel.LOW: "mdi:lightbulb-on-30", + BrightnessLevel.MEDIUM: "mdi:lightbulb-on-50", + BrightnessLevel.HIGH: "mdi:lightbulb-on", + None: "mdi:lightbulb-question", +} @dataclass class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): """A class that describes robot select entity required keys.""" - current_fn: Callable[[_RobotT], _CastTypeT] + current_fn: Callable[[_RobotT], _CastTypeT | None] options_fn: Callable[[_RobotT], list[_CastTypeT]] select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] @@ -37,26 +44,42 @@ class RobotSelectEntityDescription( """A class that describes robot select entities.""" entity_category: EntityCategory = EntityCategory.CONFIG + icon_fn: Callable[[_RobotT], str] | None = None -LITTER_ROBOT_SELECT = RobotSelectEntityDescription[LitterRobot, int]( - key="cycle_delay", - name="Clean cycle wait time minutes", - icon="mdi:timer-outline", - unit_of_measurement=UnitOfTime.MINUTES, - current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, - options_fn=lambda robot: robot.VALID_WAIT_TIMES, - select_fn=lambda robot, option: robot.set_wait_time(int(option)), -) -FEEDER_ROBOT_SELECT = RobotSelectEntityDescription[FeederRobot, float]( - key="meal_insert_size", - name="Meal insert size", - icon="mdi:scale", - unit_of_measurement="cups", - current_fn=lambda robot: robot.meal_insert_size, - options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, - select_fn=lambda robot, option: robot.set_meal_insert_size(float(option)), -) +ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { + LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( + key="cycle_delay", + name="Clean cycle wait time minutes", + icon="mdi:timer-outline", + unit_of_measurement=UnitOfTime.MINUTES, + current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, + options_fn=lambda robot: robot.VALID_WAIT_TIMES, + select_fn=lambda robot, opt: robot.set_wait_time(int(opt)), + ), + LitterRobot4: RobotSelectEntityDescription[LitterRobot4, str]( + key="panel_brightness", + name="Panel brightness", + translation_key="brightness_level", + current_fn=lambda robot: bri.name.lower() + if (bri := robot.panel_brightness) is not None + else None, + options_fn=lambda _: [level.name.lower() for level in BrightnessLevel], + select_fn=lambda robot, opt: robot.set_panel_brightness( + BrightnessLevel[opt.upper()] + ), + icon_fn=lambda robot: BRIGHTNESS_LEVEL_ICON_MAP[robot.panel_brightness], + ), + FeederRobot: RobotSelectEntityDescription[FeederRobot, float]( + key="meal_insert_size", + name="Meal insert size", + icon="mdi:scale", + unit_of_measurement="cups", + current_fn=lambda robot: robot.meal_insert_size, + options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, + select_fn=lambda robot, opt: robot.set_meal_insert_size(float(opt)), + ), +} async def async_setup_entry( @@ -66,22 +89,16 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot selects using config entry.""" hub: LitterRobotHub = hass.data[DOMAIN][config_entry.entry_id] - entities: list[LitterRobotSelect] = list( - itertools.chain( - ( - LitterRobotSelect(robot=robot, hub=hub, description=LITTER_ROBOT_SELECT) - for robot in hub.litter_robots() - ), - ( - LitterRobotSelect(robot=robot, hub=hub, description=FEEDER_ROBOT_SELECT) - for robot in hub.feeder_robots() - ), - ) - ) + entities = [ + LitterRobotSelectEntity(robot=robot, hub=hub, description=description) + for robot in hub.account.robots + for robot_type, description in ROBOT_SELECT_MAP.items() + if isinstance(robot, robot_type) + ] async_add_entities(entities) -class LitterRobotSelect( +class LitterRobotSelectEntity( LitterRobotEntity[_RobotT], SelectEntity, Generic[_RobotT, _CastTypeT] ): """Litter-Robot Select.""" @@ -99,6 +116,13 @@ class LitterRobotSelect( options = self.entity_description.options_fn(self.robot) self._attr_options = list(map(str, options)) + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + if icon_fn := self.entity_description.icon_fn: + return str(icon_fn(self.robot)) + return super().icon + @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 2d40eb6a04..b4aa8f0016 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -62,6 +62,15 @@ "spf": "Pinch Detect At Startup" } } + }, + "select": { + "brightness_level": { + "state": { + "low": "Low", + "medium": "Medium", + "high": "High" + } + } } } } diff --git a/requirements_all.txt b/requirements_all.txt index 1560012227..46599fbafe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1753,7 +1753,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.1.1 +pylitterbot==2023.1.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1235f4734..e94cabd6ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1269,7 +1269,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.1.1 +pylitterbot==2023.1.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index 478d801e4d..f6a32a6ef3 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -1,9 +1,12 @@ """Test the Litter-Robot select entity.""" -from pylitterbot import LitterRobot3 +from unittest.mock import AsyncMock, MagicMock + +from pylitterbot import LitterRobot3, LitterRobot4 import pytest from homeassistant.components.select import ( ATTR_OPTION, + ATTR_OPTIONS, DOMAIN as PLATFORM_DOMAIN, SERVICE_SELECT_OPTION, ) @@ -14,6 +17,7 @@ from homeassistant.helpers import entity_registry as er from .conftest import setup_integration SELECT_ENTITY_ID = "select.test_clean_cycle_wait_time_minutes" +PANEL_BRIGHTNESS_ENTITY_ID = "select.test_panel_brightness" async def test_wait_time_select( @@ -63,3 +67,38 @@ async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> No blocking=True, ) assert not mock_account.robots[0].set_wait_time.called + + +async def test_panel_brightness_select( + hass: HomeAssistant, + mock_account_with_litterrobot_4: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Tests the wait time select entity.""" + await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + + select = hass.states.get(PANEL_BRIGHTNESS_ENTITY_ID) + assert select + assert len(select.attributes[ATTR_OPTIONS]) == 3 + + entity_entry = entity_registry.async_get(PANEL_BRIGHTNESS_ENTITY_ID) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.CONFIG + + data = {ATTR_ENTITY_ID: PANEL_BRIGHTNESS_ENTITY_ID} + + robot: LitterRobot4 = mock_account_with_litterrobot_4.robots[0] + robot.set_panel_brightness = AsyncMock(return_value=True) + count = 0 + for option in select.attributes[ATTR_OPTIONS]: + count += 1 + data[ATTR_OPTION] = option + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SELECT_OPTION, + data, + blocking=True, + ) + + assert robot.set_panel_brightness.call_count == count From 2123600039cfbfe073d719aeaad26589213cdd7e Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 28 Mar 2023 09:10:28 -0400 Subject: [PATCH 0258/1165] Add minutely updates to relative_time and today_at template functions (#86815) * add minutely update * fix mypy --- homeassistant/helpers/template.py | 20 ++++++++++++++------ tests/helpers/test_template.py | 13 ++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8f68c7af37..f21cfc08f1 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1947,8 +1947,11 @@ def random_every_time(context, values): return random.choice(values) -def today_at(time_str: str = "") -> datetime: +def today_at(hass: HomeAssistant, time_str: str = "") -> datetime: """Record fetching now where the time has been replaced with value.""" + if (render_info := hass.data.get(_RENDER_INFO)) is not None: + render_info.has_time = True + today = dt_util.start_of_local_day() if not time_str: return today @@ -1961,7 +1964,7 @@ def today_at(time_str: str = "") -> datetime: return datetime.combine(today, time_today, today.tzinfo) -def relative_time(value): +def relative_time(hass: HomeAssistant, value: Any) -> Any: """Take a datetime and return its "age" as a string. The age can be in second, minute, hour, day, month or year. Only the @@ -1971,6 +1974,9 @@ def relative_time(value): If the input are not a datetime object the input will be returned unmodified. """ + if (render_info := hass.data.get(_RENDER_INFO)) is not None: + render_info.has_time = True + if not isinstance(value, datetime): return value if not value.tzinfo: @@ -2152,7 +2158,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["as_datetime"] = as_datetime self.filters["as_timedelta"] = as_timedelta self.filters["as_timestamp"] = forgiving_as_timestamp - self.filters["today_at"] = today_at self.filters["as_local"] = dt_util.as_local self.filters["timestamp_custom"] = timestamp_custom self.filters["timestamp_local"] = timestamp_local @@ -2178,7 +2183,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["is_number"] = is_number self.filters["float"] = forgiving_float_filter self.filters["int"] = forgiving_int_filter - self.filters["relative_time"] = relative_time self.filters["slugify"] = slugify self.filters["iif"] = iif self.filters["bool"] = forgiving_boolean @@ -2201,8 +2205,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["as_local"] = dt_util.as_local self.globals["as_timedelta"] = as_timedelta self.globals["as_timestamp"] = forgiving_as_timestamp - self.globals["today_at"] = today_at - self.globals["relative_time"] = relative_time self.globals["timedelta"] = timedelta self.globals["strptime"] = strptime self.globals["urlencode"] = urlencode @@ -2307,6 +2309,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "device_id", "area_id", "area_name", + "relative_time", + "today_at", ] hass_filters = ["closest", "expand", "device_id", "area_id", "area_name"] for glob in hass_globals: @@ -2330,6 +2334,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["states"] = self.globals["states"] self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) + self.globals["relative_time"] = hassfunction(relative_time) + self.filters["relative_time"] = self.globals["relative_time"] + self.globals["today_at"] = hassfunction(today_at) + self.filters["today_at"] = self.globals["today_at"] def is_safe_callable(self, obj): """Test if callback is safe.""" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 750602c9d6..c9ef9494bf 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1696,6 +1696,11 @@ def test_today_at( with pytest.raises(TemplateError): template.Template("{{ today_at('bad') }}", hass).async_render() + info = template.Template( + "{{ today_at('10:00').isoformat() }}", hass + ).async_render_to_info() + assert info.has_time is True + freezer.stop() @@ -1707,9 +1712,12 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" hass.config.set_time_zone("UTC") now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + relative_time_template = ( + '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' + ) with patch("homeassistant.util.dt.now", return_value=now): result = template.Template( - '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}', + relative_time_template, hass, ).async_render() assert result == "1 hour" @@ -1768,6 +1776,9 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: ).async_render() assert result == "string" + info = template.Template(relative_time_template, hass).async_render_to_info() + assert info.has_time is True + @patch( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", From b4775ed2eba3826dcfbc0b03c2b5476ccaaacaa4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 15:22:48 +0200 Subject: [PATCH 0259/1165] Don't rely on the demo integration in voice_assistant tests (#90405) --- .../snapshots/test_websocket.ambr | 2 +- .../voice_assistant/test_websocket.py | 86 ++++++++++++++----- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/tests/components/voice_assistant/snapshots/test_websocket.ambr b/tests/components/voice_assistant/snapshots/test_websocket.ambr index 07934df6c4..c18af44b21 100644 --- a/tests/components/voice_assistant/snapshots/test_websocket.ambr +++ b/tests/components/voice_assistant/snapshots/test_websocket.ambr @@ -66,7 +66,7 @@ dict({ 'tts_output': dict({ 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_demo.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_test.mp3', }), }) # --- diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index f02122a3e7..149d896dcf 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -1,15 +1,18 @@ """Websocket tests for Voice Assistant integration.""" import asyncio from collections.abc import AsyncIterable +from typing import Any from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import stt +from homeassistant.components import stt, tts from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component +from tests.common import MockModule, mock_integration, mock_platform from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import mock_get_cache_files, mock_init_cache_dir, @@ -64,6 +67,61 @@ class MockSttProvider(stt.Provider): return stt.SpeechResult(self.text, stt.SpeechResultState.SUCCESS) +class MockSTT: + """A mock STT platform.""" + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> tts.Provider: + """Set up a mock speech component.""" + return MockSttProvider(hass, _TRANSCRIPT) + + +class MockTTSProvider(tts.Provider): + """Mock TTS provider.""" + + name = "Test" + + @property + def default_language(self) -> str: + """Return the default language.""" + return "en" + + @property + def supported_languages(self) -> list[str]: + """Return list of supported languages.""" + return ["en"] + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotions.""" + return ["voice", "age"] + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + return ("mp3", b"") + + +class MockTTS: + """A mock TTS platform.""" + + PLATFORM_SCHEMA = tts.PLATFORM_SCHEMA + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> tts.Provider: + """Set up a mock speech component.""" + return MockTTSProvider() + + @pytest.fixture(autouse=True) async def init_components( hass: HomeAssistant, @@ -71,29 +129,15 @@ async def init_components( mock_init_cache_dir, # noqa: F811 ): """Initialize relevant components with empty configs.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS()) + mock_platform(hass, "test.stt", MockSTT()) + + assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) + assert await async_setup_component(hass, stt.DOMAIN, {"stt": {"platform": "test"}}) assert await async_setup_component(hass, "media_source", {}) - assert await async_setup_component( - hass, - "tts", - { - "tts": { - "platform": "demo", - } - }, - ) - assert await async_setup_component(hass, "stt", {}) - - # mock_platform fails because it can't import - hass.data[stt.DOMAIN] = {"test": MockSttProvider(hass, _TRANSCRIPT)} - assert await async_setup_component(hass, "voice_assistant", {}) - with patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=("mp3", b""), - ) as mock_get_tts: - yield mock_get_tts - async def test_text_only_pipeline( hass: HomeAssistant, From 02e2e4d0395d6000211c5142e1437dd18f803bad Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Mar 2023 16:29:24 +0200 Subject: [PATCH 0260/1165] Add rest encoding test (#90404) * Add rest encoding test * docstring --- tests/components/rest/test_sensor.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 46a972628e..5ae8530c29 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -106,6 +106,31 @@ async def test_setup_minimum(hass: HomeAssistant) -> None: assert len(hass.states.async_all("sensor")) == 1 +@respx.mock +async def test_setup_encoding(hass: HomeAssistant) -> None: + """Test setup with non-utf8 encoding.""" + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + stream=httpx.ByteStream("tack själv".encode(encoding="iso-8859-1")), + ) + assert await async_setup_component( + hass, + DOMAIN, + { + "sensor": { + "name": "mysensor", + "encoding": "iso-8859-1", + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 1 + assert hass.states.get("sensor.mysensor").state == "tack själv" + + @respx.mock async def test_manual_update(hass: HomeAssistant) -> None: """Test setup with minimum configuration.""" From bdf29b594f6b45b0cd759654b87f7d495e3c6a66 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Mar 2023 16:32:39 +0200 Subject: [PATCH 0261/1165] Replace comments with docstring in ColorMode enum (#90408) --- homeassistant/components/light/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 02f6e44a70..0c3a711a73 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -68,16 +68,20 @@ ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes" class ColorMode(StrEnum): """Possible light color modes.""" - UNKNOWN = "unknown" # Ambiguous color mode - ONOFF = "onoff" # Must be the only supported mode - BRIGHTNESS = "brightness" # Must be the only supported mode + UNKNOWN = "unknown" + """Ambiguous color mode""" + ONOFF = "onoff" + """Must be the only supported mode""" + BRIGHTNESS = "brightness" + """Must be the only supported mode""" COLOR_TEMP = "color_temp" HS = "hs" XY = "xy" RGB = "rgb" RGBW = "rgbw" RGBWW = "rgbww" - WHITE = "white" # Must *NOT* be the only supported mode + WHITE = "white" + """Must *NOT* be the only supported mode""" # These COLOR_MODE_* constants are deprecated as of Home Assistant 2022.5. From 3662c651c9511a2081bf1d85e79b627a3c8c1eaa Mon Sep 17 00:00:00 2001 From: b-uwe <61052367+b-uwe@users.noreply.github.com> Date: Tue, 28 Mar 2023 16:37:57 +0200 Subject: [PATCH 0262/1165] Add brand for HomeSeer (#90066) --- homeassistant/brands/homeseer.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/homeseer.json diff --git a/homeassistant/brands/homeseer.json b/homeassistant/brands/homeseer.json new file mode 100644 index 0000000000..cfc36968c1 --- /dev/null +++ b/homeassistant/brands/homeseer.json @@ -0,0 +1,5 @@ +{ + "domain": "homeseer", + "name": "HomeSeer", + "iot_standards": ["zwave"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1fb801be12..3e89f9d12d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2305,6 +2305,12 @@ } } }, + "homeseer": { + "name": "HomeSeer", + "iot_standards": [ + "zwave" + ] + }, "homewizard": { "name": "HomeWizard Energy", "integration_type": "hub", From abe60375b378b0f7812348b3ba0f446bb3fd269b Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Tue, 28 Mar 2023 10:43:47 -0400 Subject: [PATCH 0263/1165] Disable esphome stopping a cover if the cover doesn't support stopping (#80104) * Make "CoverEntityFeature.STOP" conditional * Check APIVersion before checking false by default flag * sort --------- Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- homeassistant/components/esphome/cover.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 99d5f16b27..9d82b28529 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from aioesphomeapi import CoverInfo, CoverOperation, CoverState +from aioesphomeapi import APIVersion, CoverInfo, CoverOperation, CoverState from homeassistant.components.cover import ( ATTR_POSITION, @@ -41,9 +41,10 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" - flags = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) + flags = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + if self._api_version < APIVersion(1, 8) or self._static_info.supports_stop: + flags |= CoverEntityFeature.STOP if self._static_info.supports_position: flags |= CoverEntityFeature.SET_POSITION if self._static_info.supports_tilt: From a26d95ec02ede0da684b2389d6cc67c0144eafc2 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 28 Mar 2023 16:45:06 +0200 Subject: [PATCH 0264/1165] Add switch tests for devolo_home_control (#80154) --- .coveragerc | 1 - tests/components/devolo_home_control/mocks.py | 23 +++++ .../devolo_home_control/test_switch.py | 86 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 tests/components/devolo_home_control/test_switch.py diff --git a/.coveragerc b/.coveragerc index 5b5096ee58..507e0dc895 100644 --- a/.coveragerc +++ b/.coveragerc @@ -198,7 +198,6 @@ omit = homeassistant/components/denonavr/__init__.py homeassistant/components/denonavr/media_player.py homeassistant/components/denonavr/receiver.py - homeassistant/components/devolo_home_control/switch.py homeassistant/components/digital_ocean/* homeassistant/components/discogs/sensor.py homeassistant/components/discord/__init__.py diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index 0fc01d6184..aef687936e 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -42,6 +42,7 @@ class BinarySwitchPropertyMock(BinarySwitchProperty): """Initialize the mock.""" self._logger = MagicMock() self.element_uid = "Test" + self.state = False class ConsumptionPropertyMock(ConsumptionProperty): @@ -233,6 +234,17 @@ class SensorMock(DeviceMock): } +class SwitchMock(DeviceMock): + """devolo Home Control switch device mock.""" + + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.binary_switch_property = { + "devolo.BinarySwitch:Test": BinarySwitchPropertyMock() + } + + class HomeControlMock(HomeControl): """devolo Home Control gateway mock.""" @@ -353,3 +365,14 @@ class HomeControlMockSiren(HomeControlMock): self.devices = {"Test": SirenMock()} self.publisher = Publisher(self.devices.keys()) self.publisher.unregister = MagicMock() + + +class HomeControlMockSwitch(HomeControlMock): + """devolo Home Control gateway mock with switch device.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + super().__init__() + self.devices = {"Test": SwitchMock()} + self.publisher = Publisher(self.devices.keys()) + self.publisher.unregister = MagicMock() diff --git a/tests/components/devolo_home_control/test_switch.py b/tests/components/devolo_home_control/test_switch.py new file mode 100644 index 0000000000..62de903848 --- /dev/null +++ b/tests/components/devolo_home_control/test_switch.py @@ -0,0 +1,86 @@ +"""Tests for the devolo Home Control switch platform.""" +from unittest.mock import patch + +from homeassistant.components.switch import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration +from .mocks import HomeControlMock, HomeControlMockSwitch + + +async def test_switch(hass: HomeAssistant): + """Test setup and state change of a switch device.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSwitch() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + assert state.state == STATE_OFF + + # Emulate websocket message: switched on + test_gateway.devices["Test"].binary_switch_property[ + "devolo.BinarySwitch:Test" + ].state = True + test_gateway.publisher.dispatch("Test", ("devolo.BinarySwitch:Test", True)) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_ON + + with patch( + "devolo_home_control_api.properties.binary_switch_property.BinarySwitchProperty.set" + ) as set_value: + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(state=True) + + set_value.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(state=False) + + # Emulate websocket message: device went offline + test_gateway.devices["Test"].status = 1 + test_gateway.publisher.dispatch("Test", ("Status", False, "status")) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + + +async def test_remove_from_hass(hass: HomeAssistant): + """Test removing entity.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSwitch() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + assert test_gateway.publisher.unregister.call_count == 1 From 866518c5a0877f73bd48620655d8f468309abc9e Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 28 Mar 2023 10:49:32 -0400 Subject: [PATCH 0265/1165] Add tests to Lidarr (#79610) * Add tests to Lidarr * fix js files * take out the trash * fix 3.9 * uno mas * fix fixture * ruff * Update const.py --------- Co-authored-by: Erik Montnemery --- .coveragerc | 3 - .prettierignore | 2 + homeassistant/components/lidarr/const.py | 2 - tests/components/lidarr/__init__.py | 52 ------- tests/components/lidarr/conftest.py | 142 ++++++++++++++++++ .../lidarr/fixtures/initialize-wrong.js | 12 ++ .../components/lidarr/fixtures/initialize.js | 12 ++ tests/components/lidarr/fixtures/queue.json | 57 +++++++ .../lidarr/fixtures/rootfolder-linux.json | 15 ++ .../lidarr/fixtures/system-status.json | 4 +- .../lidarr/fixtures/wanted-missing.json | 134 +++++++++++++++++ tests/components/lidarr/test_config_flow.py | 130 ++++++---------- tests/components/lidarr/test_init.py | 61 ++++++++ tests/components/lidarr/test_sensor.py | 33 ++++ 14 files changed, 518 insertions(+), 141 deletions(-) create mode 100644 tests/components/lidarr/conftest.py create mode 100644 tests/components/lidarr/fixtures/initialize-wrong.js create mode 100644 tests/components/lidarr/fixtures/initialize.js create mode 100644 tests/components/lidarr/fixtures/queue.json create mode 100644 tests/components/lidarr/fixtures/rootfolder-linux.json create mode 100644 tests/components/lidarr/fixtures/wanted-missing.json create mode 100644 tests/components/lidarr/test_init.py create mode 100644 tests/components/lidarr/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 507e0dc895..82677177e6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -629,9 +629,6 @@ omit = homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/__init__.py homeassistant/components/lg_soundbar/media_player.py - homeassistant/components/lidarr/__init__.py - homeassistant/components/lidarr/coordinator.py - homeassistant/components/lidarr/sensor.py homeassistant/components/life360/__init__.py homeassistant/components/life360/coordinator.py homeassistant/components/life360/device_tracker.py diff --git a/.prettierignore b/.prettierignore index a4d1d99079..aab23e2307 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,5 @@ azure-*.yml docs/source/_templates/* homeassistant/components/*/translations/*.json homeassistant/generated/* +tests/components/lidarr/fixtures/initialize.js +tests/components/lidarr/fixtures/initialize-wrong.js diff --git a/homeassistant/components/lidarr/const.py b/homeassistant/components/lidarr/const.py index feadedb6d4..ccf56db802 100644 --- a/homeassistant/components/lidarr/const.py +++ b/homeassistant/components/lidarr/const.py @@ -17,8 +17,6 @@ BYTE_SIZES = [ ] # Defaults -DEFAULT_DAYS = "1" -DEFAULT_HOST = "localhost" DEFAULT_NAME = "Lidarr" DEFAULT_UNIT = UnitOfInformation.GIGABYTES DEFAULT_MAX_RECORDS = 20 diff --git a/tests/components/lidarr/__init__.py b/tests/components/lidarr/__init__.py index 8c1220e4c6..6c1042e10d 100644 --- a/tests/components/lidarr/__init__.py +++ b/tests/components/lidarr/__init__.py @@ -1,53 +1 @@ """Tests for the Lidarr component.""" -from aiopyarr.lidarr_client import LidarrClient - -from homeassistant.components.lidarr.const import DOMAIN -from homeassistant.const import ( - CONF_API_KEY, - CONF_URL, - CONF_VERIFY_SSL, - CONTENT_TYPE_JSON, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from tests.common import MockConfigEntry, load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker - -BASE_PATH = "" -API_KEY = "1234567890abcdef1234567890abcdef" -URL = "http://127.0.0.1:8686" -client = LidarrClient(session=async_get_clientsession, api_token=API_KEY, url=URL) -API_URL = f"{URL}/api/{client._host.api_ver}" - -MOCK_REAUTH_INPUT = {CONF_API_KEY: "new_key"} - -MOCK_USER_INPUT = { - CONF_URL: URL, - CONF_VERIFY_SSL: False, -} - -CONF_DATA = MOCK_USER_INPUT | {CONF_API_KEY: API_KEY} - - -def mock_connection( - aioclient_mock: AiohttpClientMocker, - url: str = API_URL, -) -> None: - """Mock lidarr connection.""" - aioclient_mock.get( - f"{url}/system/status", - text=load_fixture("lidarr/system-status.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - -def create_entry(hass: HomeAssistant) -> MockConfigEntry: - """Create Efergy entry in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=CONF_DATA, - ) - - entry.add_to_hass(hass) - return entry diff --git a/tests/components/lidarr/conftest.py b/tests/components/lidarr/conftest.py new file mode 100644 index 0000000000..308de36954 --- /dev/null +++ b/tests/components/lidarr/conftest.py @@ -0,0 +1,142 @@ +"""Configure pytest for Lidarr tests.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Generator +from http import HTTPStatus + +from aiohttp.client_exceptions import ClientError +from aiopyarr.lidarr_client import LidarrClient +import pytest + +from homeassistant.components.lidarr.const import DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_URL, + CONF_VERIFY_SSL, + CONTENT_TYPE_JSON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +URL = "http://127.0.0.1:8668" +API_KEY = "1234567890abcdef1234567890abcdef" +client = LidarrClient(session=async_get_clientsession, api_token=API_KEY, url=URL) +API_URL = f"{URL}/api/{client._host.api_ver}" + +MOCK_INPUT = {CONF_URL: URL, CONF_VERIFY_SSL: False} + +CONF_DATA = MOCK_INPUT | {CONF_API_KEY: API_KEY} + +ComponentSetup = Callable[[], Awaitable[None]] + + +def mock_error( + aioclient_mock: AiohttpClientMocker, status: HTTPStatus | None = None +) -> None: + """Mock an error.""" + if status: + aioclient_mock.get(f"{API_URL}/queue", status=status) + aioclient_mock.get(f"{API_URL}/rootfolder", status=status) + aioclient_mock.get(f"{API_URL}/system/status", status=status) + aioclient_mock.get(f"{API_URL}/wanted/missing", status=status) + aioclient_mock.get(f"{API_URL}/queue", exc=ClientError) + aioclient_mock.get(f"{API_URL}/rootfolder", exc=ClientError) + aioclient_mock.get(f"{API_URL}/system/status", exc=ClientError) + aioclient_mock.get(f"{API_URL}/wanted/missing", exc=ClientError) + + +@pytest.fixture +def cannot_connect(aioclient_mock: AiohttpClientMocker) -> None: + """Mock cannot connect error.""" + mock_error(aioclient_mock, status=HTTPStatus.INTERNAL_SERVER_ERROR) + + +@pytest.fixture +def invalid_auth(aioclient_mock: AiohttpClientMocker) -> None: + """Mock invalid authorization error.""" + mock_error(aioclient_mock, status=HTTPStatus.UNAUTHORIZED) + + +@pytest.fixture +def wrong_app(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr wrong app.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text=load_fixture("lidarr/initialize-wrong.js"), + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture +def zeroconf_failed(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr zero configuration failure.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text="login-failed", + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture +def unknown(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr unknown error.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text="something went wrong", + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture(name="connection") +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr connection.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text=load_fixture("lidarr/initialize.js"), + headers={"Content-Type": "application/javascript"}, + ) + aioclient_mock.get( + f"{API_URL}/system/status", + text=load_fixture("lidarr/system-status.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"{API_URL}/queue", + text=load_fixture("lidarr/queue.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"{API_URL}/wanted/missing", + text=load_fixture("lidarr/wanted-missing.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"{API_URL}/rootfolder", + text=load_fixture("lidarr/rootfolder-linux.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create Lidarr entry in Home Assistant.""" + return MockConfigEntry(domain=DOMAIN, data=CONF_DATA) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> Generator[ComponentSetup, None, None]: + """Set up the lidarr integration in Home Assistant.""" + config_entry.add_to_hass(hass) + + async def func() -> None: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return func diff --git a/tests/components/lidarr/fixtures/initialize-wrong.js b/tests/components/lidarr/fixtures/initialize-wrong.js new file mode 100644 index 0000000000..9d92f564da --- /dev/null +++ b/tests/components/lidarr/fixtures/initialize-wrong.js @@ -0,0 +1,12 @@ +window.Radarr = { + apiRoot: '/api/v3', + apiKey: '1234567890abcdef1234567890abcdef', + release: '4.0.3.5849-develop', + version: '4.0.3.5849', + instanceName: 'Radarr', + branch: 'nightly', + analytics: true, + userHash: 'abcd1234', + urlBase: '', + isProduction: true + }; \ No newline at end of file diff --git a/tests/components/lidarr/fixtures/initialize.js b/tests/components/lidarr/fixtures/initialize.js new file mode 100644 index 0000000000..d50aaf9cc6 --- /dev/null +++ b/tests/components/lidarr/fixtures/initialize.js @@ -0,0 +1,12 @@ +window.Lidarr = { + apiRoot: '/api/v1', + apiKey: '1234567890abcdef1234567890abcdef', + release: '10.0.0.34882-develop', + version: '10.0.0.34882', + instanceName: 'Lidarr', + branch: 'nightly', + analytics: true, + userHash: 'abcd1234', + urlBase: '', + isProduction: true + }; \ No newline at end of file diff --git a/tests/components/lidarr/fixtures/queue.json b/tests/components/lidarr/fixtures/queue.json new file mode 100644 index 0000000000..24a922b97a --- /dev/null +++ b/tests/components/lidarr/fixtures/queue.json @@ -0,0 +1,57 @@ +{ + "page": 1, + "pageSize": 20, + "sortKey": "timeleft", + "sortDirection": "default", + "totalRecords": 2, + "records": [ + { + "artistId": 1, + "albumId": 1, + "quality": { + "quality": { "id": 0, "name": "Unknown" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + }, + "size": 1000000, + "title": "string", + "sizeleft": 100000, + "timeleft": "00:00:00", + "estimatedCompletionTime": "2020-09-26T18:47:46Z", + "status": "downloading", + "trackedDownloadStatus": "ok", + "trackedDownloadState": "downloading", + "statusMessages": [], + "downloadId": "string", + "protocol": "string", + "downloadClient": "testclient", + "indexer": "test", + "outputPath": "/downloads/string", + "downloadForced": false, + "id": 1 + }, + { + "artistId": 1, + "albumId": 1, + "quality": { + "quality": { "id": 0, "name": "Unknown" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + }, + "size": 2000000, + "title": "string2", + "sizeleft": 1000000, + "timeleft": "00:00:10", + "estimatedCompletionTime": "2020-09-26T18:47:46Z", + "status": "downloading", + "trackedDownloadStatus": "ok", + "trackedDownloadState": "downloading", + "statusMessages": [], + "downloadId": "string", + "protocol": "string", + "downloadClient": "testclient", + "indexer": "test", + "outputPath": "/downloads/string", + "downloadForced": false, + "id": 1 + } + ] +} diff --git a/tests/components/lidarr/fixtures/rootfolder-linux.json b/tests/components/lidarr/fixtures/rootfolder-linux.json new file mode 100644 index 0000000000..070703279b --- /dev/null +++ b/tests/components/lidarr/fixtures/rootfolder-linux.json @@ -0,0 +1,15 @@ +[ + { + "name": "/music/", + "path": "/music/", + "defaultMetadataProfileId": 1, + "defaultQualityProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [], + "accessible": true, + "freeSpace": 1000000000, + "totalSpace": 100000000000, + "id": 2 + } +] diff --git a/tests/components/lidarr/fixtures/system-status.json b/tests/components/lidarr/fixtures/system-status.json index 6baa9428ff..bd49c58153 100644 --- a/tests/components/lidarr/fixtures/system-status.json +++ b/tests/components/lidarr/fixtures/system-status.json @@ -5,8 +5,8 @@ "isProduction": false, "isAdmin": false, "isUserInteractive": true, - "startupPath": "C:\\ProgramData\\Radarr", - "appData": "C:\\ProgramData\\Radarr", + "startupPath": "C:\\ProgramData\\Lidarr", + "appData": "C:\\ProgramData\\Lidarr", "osName": "Windows", "osVersion": "10.0.18363.0", "isNetCore": true, diff --git a/tests/components/lidarr/fixtures/wanted-missing.json b/tests/components/lidarr/fixtures/wanted-missing.json new file mode 100644 index 0000000000..2b5886b39d --- /dev/null +++ b/tests/components/lidarr/fixtures/wanted-missing.json @@ -0,0 +1,134 @@ +{ + "page": 1, + "pageSize": 20, + "sortKey": "title", + "sortDirection": "default", + "totalRecords": 1, + "records": [ + { + "title": "test", + "disambiguation": "string", + "overview": "string", + "artistId": 0, + "foreignAlbumId": "string", + "monitored": true, + "anyReleaseOk": true, + "profileId": 1, + "duration": 0, + "albumType": "Album", + "secondaryTypes": [ + { + "id": 0, + "name": "string" + } + ], + "mediumCount": 1, + "ratings": { + "votes": 0, + "value": 0 + }, + "releaseDate": "1968-01-01T00:00:00Z", + "releases": [ + { + "id": 0, + "albumId": 0, + "foreignReleaseId": "string", + "title": "string", + "status": "string", + "duration": 0, + "trackCount": 1, + "media": [ + { + "mediumNumber": 1, + "mediumName": "Unknown", + "mediumFormat": "Unknown" + } + ], + "mediumCount": 1, + "disambiguation": "", + "country": ["string"], + "label": ["test"], + "format": "Unknown", + "monitored": true + } + ], + "genres": ["string"], + "media": [ + { + "mediumNumber": 1, + "mediumName": "Unknown", + "mediumFormat": "Unknown" + } + ], + "artist": { + "artistMetadataId": 0, + "status": "continuing", + "ended": false, + "artistName": "test", + "foreignArtistId": "string", + "tadbId": 0, + "discogsId": 0, + "overview": "string", + "artistType": "Group", + "disambiguation": "", + "links": [ + { + "url": "string", + "name": "string" + } + ], + "images": [ + { + "url": "https://test.jpg", + "coverType": "fanart", + "extension": ".jpg" + } + ], + "path": "string", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["string"], + "cleanName": "string", + "sortName": "string", + "tags": [0], + "added": "2020-03-18T15:51:22Z", + "ratings": { + "votes": 0, + "value": 0 + }, + "statistics": { + "albumCount": 0, + "trackFileCount": 0, + "trackCount": 0, + "totalTrackCount": 0, + "sizeOnDisk": 0, + "percentOfTracks": 0 + }, + "id": 0 + }, + "images": [ + { + "url": "string", + "coverType": "poster" + } + ], + "links": [ + { + "url": "string", + "name": "string" + } + ], + "statistics": { + "trackFileCount": 0, + "trackCount": 1, + "totalTrackCount": 1, + "sizeOnDisk": 0, + "percentOfTracks": 0 + }, + "grabbed": false, + "id": 1 + } + ] +} diff --git a/tests/components/lidarr/test_config_flow.py b/tests/components/lidarr/test_config_flow.py index b785932434..d3c4352dc1 100644 --- a/tests/components/lidarr/test_config_flow.py +++ b/tests/components/lidarr/test_config_flow.py @@ -1,138 +1,104 @@ """Test Lidarr config flow.""" -from unittest.mock import patch - -from aiopyarr import exceptions - from homeassistant import data_entry_flow from homeassistant.components.lidarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_SOURCE from homeassistant.core import HomeAssistant -from . import API_KEY, CONF_DATA, MOCK_USER_INPUT, create_entry, mock_connection - -from tests.test_util.aiohttp import AiohttpClientMocker +from .conftest import CONF_DATA, MOCK_INPUT, ComponentSetup -def _patch_client(): - return patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_get_system_status" - ) - - -async def test_flow_user_form( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_flow_user_form(hass: HomeAssistant, connection) -> None: """Test that the user set up form is served.""" - mock_connection(aioclient_mock) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, ) - with patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_try_zeroconf", - return_value=("/api/v3", API_KEY, ""), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_USER_INPUT, - ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_INPUT, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == DEFAULT_NAME assert result["data"] == CONF_DATA -async def test_flow_user_invalid_auth(hass: HomeAssistant) -> None: +async def test_flow_user_invalid_auth(hass: HomeAssistant, invalid_auth) -> None: """Test invalid authentication.""" - with _patch_client() as client: - client.side_effect = exceptions.ArrAuthenticationException - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_DATA, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=CONF_DATA, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "invalid_auth" -async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: +async def test_flow_user_cannot_connect(hass: HomeAssistant, cannot_connect) -> None: """Test connection error.""" - with _patch_client() as client: - client.side_effect = exceptions.ArrConnectionException - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_DATA, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "cannot_connect" -async def test_wrong_app(hass: HomeAssistant) -> None: +async def test_wrong_app(hass: HomeAssistant, wrong_app) -> None: """Test we show user form on wrong app.""" - with patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_try_zeroconf", - side_effect=exceptions.ArrWrongAppException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data=MOCK_USER_INPUT, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=MOCK_INPUT, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "wrong_app" -async def test_zero_conf_failure(hass: HomeAssistant) -> None: - """Test we show user form on api key retrieval failure.""" - with patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_try_zeroconf", - side_effect=exceptions.ArrZeroConfException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data=MOCK_USER_INPUT, - ) +async def test_zeroconf_failed(hass: HomeAssistant, zeroconf_failed) -> None: + """Test we show user form on zeroconf failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=MOCK_INPUT, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "zeroconf_failed" -async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: +async def test_flow_user_unknown_error(hass: HomeAssistant, unknown) -> None: """Test unknown error.""" - with _patch_client() as client: - client.side_effect = exceptions.ArrException - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_DATA, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "unknown" async def test_flow_reauth( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, setup_integration: ComponentSetup, connection ) -> None: """Test reauth.""" - entry = create_entry(hass) - mock_connection(aioclient_mock) + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] result = await hass.config_entries.flow.async_init( DOMAIN, context={ diff --git a/tests/components/lidarr/test_init.py b/tests/components/lidarr/test_init.py new file mode 100644 index 0000000000..2a217bebd5 --- /dev/null +++ b/tests/components/lidarr/test_init.py @@ -0,0 +1,61 @@ +"""Test Lidarr integration.""" +from homeassistant.components.lidarr.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import ComponentSetup + + +async def test_setup( + hass: HomeAssistant, setup_integration: ComponentSetup, connection +) -> None: + """Test setup.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_not_ready( + hass: HomeAssistant, setup_integration: ComponentSetup, cannot_connect +) -> None: + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_auth_failed( + hass: HomeAssistant, setup_integration: ComponentSetup, invalid_auth +) -> None: + """Test that it throws ConfigEntryAuthFailed when authentication fails.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) + + +async def test_device_info( + hass: HomeAssistant, setup_integration: ComponentSetup, connection +) -> None: + """Test device info.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + device_registry = dr.async_get(hass) + await hass.async_block_till_done() + device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + + assert device.configuration_url == "http://127.0.0.1:8668" + assert device.identifiers == {(DOMAIN, entry.entry_id)} + assert device.manufacturer == DEFAULT_NAME + assert device.name == "Mock Title" + assert device.sw_version == "10.0.0.34882" diff --git a/tests/components/lidarr/test_sensor.py b/tests/components/lidarr/test_sensor.py new file mode 100644 index 0000000000..7fe347f461 --- /dev/null +++ b/tests/components/lidarr/test_sensor.py @@ -0,0 +1,33 @@ +"""The tests for Lidarr sensor platform.""" +from unittest.mock import AsyncMock + +from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT +from homeassistant.core import HomeAssistant + +from .conftest import ComponentSetup + + +async def test_sensors( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry_enabled_by_default: AsyncMock, + connection, +): + """Test for successfully setting up the Lidarr platform.""" + await setup_integration() + + state = hass.states.get("sensor.mock_title_disk_space") + assert state.state == "0.93" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "GB" + state = hass.states.get("sensor.mock_title_queue") + assert state.state == "2" + assert state.attributes.get("string") == "stopped" + assert state.attributes.get("string2") == "downloading" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Albums" + assert state.attributes.get(CONF_STATE_CLASS) == SensorStateClass.TOTAL + state = hass.states.get("sensor.mock_title_wanted") + assert state.state == "1" + assert state.attributes.get("test") == "test" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Albums" + assert state.attributes.get(CONF_STATE_CLASS) == SensorStateClass.TOTAL From f081fa8febbd0f4cd2738077726419a903eb3a29 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Tue, 28 Mar 2023 15:50:59 +0100 Subject: [PATCH 0266/1165] Add basic tests for temper USB temperature sensor integration (#80220) * Add basic tests * Updated requriements_test_all.txt * Update temperusb version * Add type hints Co-authored-by: Christian Knittl-Frank * Add type hints Co-authored-by: Christian Knittl-Frank * Correct typo in type hint * Fix isort * Fix requirements_test_all.txt --------- Co-authored-by: Dave T Co-authored-by: Christian Knittl-Frank --- requirements_test_all.txt | 3 +++ tests/components/temper/__init__.py | 1 + tests/components/temper/test_sensor.py | 35 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/components/temper/__init__.py create mode 100644 tests/components/temper/test_sensor.py diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e94cabd6ac..7b41d46936 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1760,6 +1760,9 @@ tellduslive==0.10.11 # homeassistant.components.lg_soundbar temescal==0.5 +# homeassistant.components.temper +temperusb==1.6.0 + # homeassistant.components.powerwall tesla-powerwall==0.3.19 diff --git a/tests/components/temper/__init__.py b/tests/components/temper/__init__.py new file mode 100644 index 0000000000..6ce341cabc --- /dev/null +++ b/tests/components/temper/__init__.py @@ -0,0 +1 @@ +"""Tests for the temper integration.""" diff --git a/tests/components/temper/test_sensor.py b/tests/components/temper/test_sensor.py new file mode 100644 index 0000000000..d195ff85c8 --- /dev/null +++ b/tests/components/temper/test_sensor.py @@ -0,0 +1,35 @@ +"""The tests for the temper (USB temperature sensor) component.""" +from datetime import timedelta +from unittest.mock import Mock, patch + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import async_fire_time_changed + + +async def test_temperature_readback(hass: HomeAssistant) -> None: + """Test for reading sensors.""" + mock_temper_device = Mock() + mock_temper_device.get_temperature.return_value = 12.3 + + utcnow = dt_util.utcnow() + + with patch( + "temperusb.temper.TemperHandler.get_devices", + return_value=[mock_temper_device], + ): + await async_setup_component( + hass, + "sensor", + {"sensor": {"platform": "temper", "name": "mydevicename"}}, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, utcnow + timedelta(seconds=70)) + await hass.async_block_till_done() + + temperature = hass.states.get("sensor.mydevicename") + assert temperature + assert temperature.state == "12.3" From 048d30904e62d61fd5d5ab94635e381c94b6fdd4 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Tue, 28 Mar 2023 10:52:16 -0400 Subject: [PATCH 0267/1165] Simplify qbittorrent sensor class init (#90411) catch LoginException directly in QBittorrentSensor init Since the `exception` arg in QBittorrentSensor `__init__` is always LoginException, we catch LoginException directly in `__init__` instead of passing LoginException as an argument. --- homeassistant/components/qbittorrent/sensor.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index bee7a5d61a..cafb8d8b21 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -90,8 +90,7 @@ def setup_platform( name = config.get(CONF_NAME) entities = [ - QBittorrentSensor(description, client, name, LoginRequired) - for description in SENSOR_TYPES + QBittorrentSensor(description, client, name) for description in SENSOR_TYPES ] add_entities(entities, True) @@ -111,12 +110,10 @@ class QBittorrentSensor(SensorEntity): description: SensorEntityDescription, qbittorrent_client, client_name, - exception, ) -> None: """Initialize the qBittorrent sensor.""" self.entity_description = description self.client = qbittorrent_client - self._exception = exception self._attr_name = f"{client_name} {description.name}" self._attr_available = False @@ -130,7 +127,7 @@ class QBittorrentSensor(SensorEntity): _LOGGER.error("Connection lost") self._attr_available = False return - except self._exception: + except LoginRequired: _LOGGER.error("Invalid authentication") return From e45eab600ff053928a7bf1566984e53ceb31557f Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 28 Mar 2023 09:04:29 -0600 Subject: [PATCH 0268/1165] Add has_value function/test to Jinja2 template (#79550) --- homeassistant/helpers/template.py | 26 ++++++++++++++++++++- tests/helpers/test_template.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index f21cfc08f1..d3aa7c81ff 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -49,6 +49,7 @@ from homeassistant.const import ( ATTR_LONGITUDE, ATTR_PERSONS, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfLength, ) @@ -1470,6 +1471,15 @@ def state_attr(hass: HomeAssistant, entity_id: str, name: str) -> Any: return None +def has_value(hass: HomeAssistant, entity_id: str) -> bool: + """Test if an entity has a valid value.""" + state_obj = _get_state(hass, entity_id) + + return state_obj is not None and ( + state_obj.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN] + ) + + def now(hass: HomeAssistant) -> datetime: """Record fetching now.""" if (render_info := hass.data.get(_RENDER_INFO)) is not None: @@ -2302,6 +2312,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "is_state_attr", "state_attr", "states", + "has_value", "utcnow", "now", "device_attr", @@ -2312,11 +2323,21 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "relative_time", "today_at", ] - hass_filters = ["closest", "expand", "device_id", "area_id", "area_name"] + hass_filters = [ + "closest", + "expand", + "device_id", + "area_id", + "area_name", + "has_value", + ] + hass_tests = ["has_value"] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: self.filters[filt] = unsupported(filt) + for test in hass_tests: + self.filters[test] = unsupported(test) return self.globals["expand"] = hassfunction(expand) @@ -2332,6 +2353,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["state_attr"] = self.globals["state_attr"] self.globals["states"] = AllStates(hass) self.filters["states"] = self.globals["states"] + self.globals["has_value"] = hassfunction(has_value) + self.filters["has_value"] = pass_context(self.globals["has_value"]) + self.tests["has_value"] = pass_eval_context(self.globals["has_value"]) self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) self.globals["relative_time"] = hassfunction(relative_time) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index c9ef9494bf..45237a5cbf 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -21,6 +21,7 @@ from homeassistant.const import ( LENGTH_MILLIMETERS, MASS_GRAMS, STATE_ON, + STATE_UNAVAILABLE, TEMP_CELSIUS, VOLUME_LITERS, UnitOfPressure, @@ -1607,6 +1608,44 @@ def test_states_function(hass: HomeAssistant) -> None: assert tpl.async_render() == "available" +def test_has_value(hass): + """Test has_value method.""" + hass.states.async_set("test.value1", 1) + hass.states.async_set("test.unavailable", STATE_UNAVAILABLE) + + tpl = template.Template( + """ +{{ has_value("test.value1") }} + """, + hass, + ) + assert tpl.async_render() is True + + tpl = template.Template( + """ +{{ has_value("test.unavailable") }} + """, + hass, + ) + assert tpl.async_render() is False + + tpl = template.Template( + """ +{{ has_value("test.unknown") }} + """, + hass, + ) + assert tpl.async_render() is False + + tpl = template.Template( + """ +{% if "test.value1" is has_value %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + @patch( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, From 478a1d5e9a15334cba10a51eb7be50a95aa66f4c Mon Sep 17 00:00:00 2001 From: Wesley Vos <17592840+Wesley-Vos@users.noreply.github.com> Date: Tue, 28 Mar 2023 17:09:20 +0200 Subject: [PATCH 0269/1165] Add periodically resetting meter option to utility meter (#88446) * Use last valid state if meter is not periodically resetting * Fix unload of entry, used during options flow submit * Adjustments based on code review * Move DecimalException handling to validation method * Add test for invalid new state in calculate_adjustment method --- .../components/utility_meter/__init__.py | 26 +- .../components/utility_meter/config_flow.py | 10 + .../components/utility_meter/const.py | 1 + .../components/utility_meter/sensor.py | 133 +++++--- .../components/utility_meter/strings.json | 8 +- .../utility_meter/test_config_flow.py | 61 +++- tests/components/utility_meter/test_init.py | 3 + tests/components/utility_meter/test_sensor.py | 317 +++++++++++++++++- 8 files changed, 510 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index c436ea757a..11e58fca77 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -21,6 +21,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFF, @@ -83,6 +84,7 @@ METER_CONFIG_SCHEMA = vol.Schema( ), vol.Optional(CONF_METER_DELTA_VALUES, default=False): cv.boolean, vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, + vol.Optional(CONF_METER_PERIODICALLY_RESETTING, default=True): cv.boolean, vol.Optional(CONF_TARIFFS, default=[]): vol.All( cv.ensure_list, vol.Unique(), [cv.string] ), @@ -221,13 +223,29 @@ async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + platforms_to_unload = [Platform.SENSOR] + if entry.options.get(CONF_TARIFFS): + platforms_to_unload.append(Platform.SELECT) + if unload_ok := await hass.config_entries.async_unload_platforms( entry, - ( - Platform.SELECT, - Platform.SENSOR, - ), + platforms_to_unload, ): hass.data[DATA_UTILITY].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + new = {**config_entry.options} + new[CONF_METER_PERIODICALLY_RESETTING] = True + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, options=new) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index c1f82e902d..eb5c19941d 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -21,6 +21,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFFS, @@ -64,6 +65,9 @@ OPTIONS_SCHEMA = vol.Schema( vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig(domain=SENSOR_DOMAIN), ), + vol.Required( + CONF_METER_PERIODICALLY_RESETTING, + ): selector.BooleanSelector(), } ) @@ -95,6 +99,10 @@ CONFIG_SCHEMA = vol.Schema( vol.Required( CONF_METER_DELTA_VALUES, default=False ): selector.BooleanSelector(), + vol.Required( + CONF_METER_PERIODICALLY_RESETTING, + default=True, + ): selector.BooleanSelector(), } ) @@ -110,6 +118,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Utility Meter.""" + VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 9b85e9e3ae..f8a4c2d4b7 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -32,6 +32,7 @@ CONF_METER_TYPE = "cycle" CONF_METER_OFFSET = "offset" CONF_METER_DELTA_VALUES = "delta_values" CONF_METER_NET_CONSUMPTION = "net_consumption" +CONF_METER_PERIODICALLY_RESETTING = "periodically_resetting" CONF_PAUSED = "paused" CONF_TARIFFS = "tariffs" CONF_TARIFF = "tariff" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 066a3cd6e1..dad2d8dfaf 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfEnergy, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -50,6 +50,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFF, @@ -85,6 +86,7 @@ ATTR_SOURCE_ID = "source" ATTR_STATUS = "status" ATTR_PERIOD = "meter_period" ATTR_LAST_PERIOD = "last_period" +ATTR_LAST_VALID_STATE = "last_valid_state" ATTR_TARIFF = "tariff" DEVICE_CLASS_MAP = { @@ -127,6 +129,7 @@ async def async_setup_entry( meter_type = None name = config_entry.title net_consumption = config_entry.options[CONF_METER_NET_CONSUMPTION] + periodically_resetting = config_entry.options[CONF_METER_PERIODICALLY_RESETTING] tariff_entity = hass.data[DATA_UTILITY][entry_id][CONF_TARIFF_ENTITY] meters = [] @@ -142,6 +145,7 @@ async def async_setup_entry( name=name, net_consumption=net_consumption, parent_meter=entry_id, + periodically_resetting=periodically_resetting, source_entity=source_entity_id, tariff_entity=tariff_entity, tariff=None, @@ -160,6 +164,7 @@ async def async_setup_entry( name=f"{name} {tariff}", net_consumption=net_consumption, parent_meter=entry_id, + periodically_resetting=periodically_resetting, source_entity=source_entity_id, tariff_entity=tariff_entity, tariff=tariff, @@ -223,6 +228,9 @@ async def async_setup_platform( conf_meter_net_consumption = hass.data[DATA_UTILITY][meter][ CONF_METER_NET_CONSUMPTION ] + conf_meter_periodically_resetting = hass.data[DATA_UTILITY][meter][ + CONF_METER_PERIODICALLY_RESETTING + ] conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get( CONF_TARIFF_ENTITY ) @@ -235,6 +243,7 @@ async def async_setup_platform( name=conf_sensor_name, net_consumption=conf_meter_net_consumption, parent_meter=meter, + periodically_resetting=conf_meter_periodically_resetting, source_entity=conf_meter_source, tariff_entity=conf_meter_tariff_entity, tariff=conf_sensor_tariff, @@ -262,6 +271,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): last_period: Decimal last_reset: datetime | None + last_valid_state: Decimal | None status: str def as_dict(self) -> dict[str, Any]: @@ -270,6 +280,9 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): data["last_period"] = str(self.last_period) if isinstance(self.last_reset, (datetime)): data["last_reset"] = self.last_reset.isoformat() + data["last_valid_state"] = ( + str(self.last_valid_state) if self.last_valid_state else None + ) data["status"] = self.status return data @@ -284,6 +297,11 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): try: last_period: Decimal = Decimal(restored["last_period"]) last_reset: datetime | None = dt_util.parse_datetime(restored["last_reset"]) + last_valid_state: Decimal | None = ( + Decimal(restored["last_valid_state"]) + if restored.get("last_valid_state") + else None + ) status: str = restored["status"] except KeyError: # restored is a dict, but does not have all values @@ -297,6 +315,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): extra.native_unit_of_measurement, last_period, last_reset, + last_valid_state, status, ) @@ -316,6 +335,7 @@ class UtilityMeterSensor(RestoreSensor): name, net_consumption, parent_meter, + periodically_resetting, source_entity, tariff_entity, tariff, @@ -330,6 +350,7 @@ class UtilityMeterSensor(RestoreSensor): self._state = None self._last_period = Decimal(0) self._last_reset = dt_util.utcnow() + self._last_valid_state = None self._collecting = None self._name = name self._unit_of_measurement = None @@ -346,6 +367,7 @@ class UtilityMeterSensor(RestoreSensor): self._cron_pattern = cron_pattern self._sensor_delta_values = delta_values self._sensor_net_consumption = net_consumption + self._sensor_periodically_resetting = periodically_resetting self._tariff = tariff self._tariff_entity = tariff_entity @@ -355,53 +377,70 @@ class UtilityMeterSensor(RestoreSensor): self._state = 0 self.async_write_ha_state() - @callback - def async_reading(self, event): - """Handle the sensor state changes.""" - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + @staticmethod + def _validate_state(state: State | None) -> Decimal | None: + """Parse the state as a Decimal if available. Throws DecimalException if the state is not a number.""" + try: + return ( + None + if state is None or state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN] + else Decimal(state.state) + ) + except DecimalException: + return None - if self._state is None and new_state.state: + def calculate_adjustment( + self, old_state: State | None, new_state: State + ) -> Decimal | None: + """Calculate the adjustment based on the old and new state.""" + + # First check if the new_state is valid (see discussion in PR #88446) + if (new_state_val := self._validate_state(new_state)) is None: + _LOGGER.warning("Invalid state %s", new_state.state) + return None + + if self._sensor_delta_values: + return new_state_val + + if ( + not self._sensor_periodically_resetting + and self._last_valid_state is not None + ): # Fallback to old_state if sensor is periodically resetting but last_valid_state is None + return new_state_val - self._last_valid_state + + if (old_state_val := self._validate_state(old_state)) is not None: + return new_state_val - old_state_val + _LOGGER.warning( + "Invalid state (%s > %s)", + old_state.state if old_state else None, + new_state_val, + ) + return None + + @callback + def async_reading(self, event: Event): + """Handle the sensor state changes.""" + old_state: State | None = event.data.get("old_state") + new_state: State = event.data.get("new_state") # type: ignore[assignment] # a state change event always has a new state + + if (new_state_val := self._validate_state(new_state)) is None: + _LOGGER.warning("Invalid state %s", new_state.state) + return + + if self._state is None: # First state update initializes the utility_meter sensors - source_state = self.hass.states.get(self._sensor_source_id) for sensor in self.hass.data[DATA_UTILITY][self._parent_meter][ DATA_TARIFF_SENSORS ]: - sensor.start(source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + sensor.start(new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) if ( - new_state is None - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or ( - not self._sensor_delta_values - and ( - old_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - ) - ) - ): - return + adjustment := self.calculate_adjustment(old_state, new_state) + ) is not None and (self._sensor_net_consumption or adjustment >= 0): + # If net_consumption is off, the adjustment must be non-negative + self._state += adjustment # type: ignore[operator] # self._state will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line - self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - - try: - if self._sensor_delta_values: - adjustment = Decimal(new_state.state) - else: - adjustment = Decimal(new_state.state) - Decimal(old_state.state) - - if (not self._sensor_net_consumption) and adjustment < 0: - # Source sensor just rolled over for unknown reasons, - return - self._state += adjustment - - except DecimalException as err: - if self._sensor_delta_values: - _LOGGER.warning("Invalid adjustment of %s: %s", new_state.state, err) - else: - _LOGGER.warning( - "Invalid state (%s > %s): %s", old_state.state, new_state.state, err - ) + self._last_valid_state = new_state_val self.async_write_ha_state() @callback @@ -422,6 +461,11 @@ class UtilityMeterSensor(RestoreSensor): self._collecting() self._collecting = None + # Reset the last_valid_state during state change because if the last state before the tariff change was invalid, + # there is no way to know how much "adjustment" counts for which tariff. Therefore, we set the last_valid_state + # to None and let the fallback mechanism handle the case that the old state was valid + self._last_valid_state = None + _LOGGER.debug( "%s - %s - source <%s>", self._name, @@ -484,6 +528,7 @@ class UtilityMeterSensor(RestoreSensor): self._unit_of_measurement = last_sensor_data.native_unit_of_measurement self._last_period = last_sensor_data.last_period self._last_reset = last_sensor_data.last_reset + self._last_valid_state = last_sensor_data.last_valid_state if last_sensor_data.status == COLLECTING: # Null lambda to allow cancelling the collection on tariff change self._collecting = lambda: None @@ -508,6 +553,12 @@ class UtilityMeterSensor(RestoreSensor): and is_number(state.attributes[ATTR_LAST_PERIOD]) else Decimal(0) ) + self._last_valid_state = ( + Decimal(state.attributes[ATTR_LAST_VALID_STATE]) + if state.attributes.get(ATTR_LAST_VALID_STATE) + and is_number(state.attributes[ATTR_LAST_VALID_STATE]) + else None + ) self._last_reset = dt_util.as_utc( dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) ) @@ -590,6 +641,7 @@ class UtilityMeterSensor(RestoreSensor): ATTR_SOURCE_ID: self._sensor_source_id, ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING, ATTR_LAST_PERIOD: str(self._last_period), + ATTR_LAST_VALID_STATE: str(self._last_valid_state), } if self._period is not None: state_attr[ATTR_PERIOD] = self._period @@ -620,6 +672,7 @@ class UtilityMeterSensor(RestoreSensor): self.native_unit_of_measurement, self._last_period, self._last_reset, + self._last_valid_state, PAUSED if self._collecting is None else COLLECTING, ) diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index e9f8e7f250..1eeacbae80 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -9,6 +9,7 @@ "cycle": "Meter reset cycle", "delta_values": "Delta values", "name": "Name", + "periodically_resetting": "Periodically resetting", "net_consumption": "Net consumption", "offset": "Meter reset offset", "source": "Input sensor", @@ -17,6 +18,7 @@ "data_description": { "delta_values": "Enable if the source values are delta values since the last reading instead of absolute values.", "net_consumption": "Enable if the source is a net meter, meaning it can both increase and decrease.", + "periodically_resetting": "Enable if the source may periodically reset to 0, for example at boot of the measuring device. If disabled, new readings are directly recorded after data inavailability.", "offset": "Offset the day of a monthly meter reset.", "tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed." } @@ -27,7 +29,11 @@ "step": { "init": { "data": { - "source": "[%key:component::utility_meter::config::step::user::data::source%]" + "source": "[%key:component::utility_meter::config::step::user::data::source%]", + "periodically_resetting": "[%key:component::utility_meter::config::step::user::data::periodically_resetting%]" + }, + "data_description": { + "periodically_resetting": "[%key:component::utility_meter::config::step::user::data_description::periodically_resetting%]" } } } diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 8deb7601aa..302d3879a0 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -47,6 +47,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": [], } @@ -60,6 +61,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": [], } @@ -96,6 +98,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "delta_values": False, "name": "Electricity meter", "net_consumption": False, + "periodically_resetting": True, "offset": 0, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], @@ -109,6 +112,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], } @@ -136,6 +140,57 @@ async def test_tariffs(hass: HomeAssistant) -> None: assert result["errors"]["base"] == "tariffs_not_unique" +async def test_non_periodically_resetting(hass: HomeAssistant) -> None: + """Test periodically resetting.""" + input_sensor_entity_id = "sensor.input" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "cycle": "monthly", + "name": "Electricity meter", + "offset": 0, + "periodically_resetting": False, + "source": input_sensor_entity_id, + "tariffs": [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Electricity meter" + assert result["data"] == {} + assert result["options"] == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "periodically_resetting": False, + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": [], + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "source": input_sensor_entity_id, + "tariffs": [], + } + + def get_suggested(schema, key): """Get suggested value for key in voluptuous schema.""" for k in schema: @@ -162,6 +217,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor1_entity_id, "tariffs": "", }, @@ -176,10 +232,11 @@ async def test_options(hass: HomeAssistant) -> None: assert result["step_id"] == "init" schema = result["data_schema"].schema assert get_suggested(schema, "source") == input_sensor1_entity_id + assert get_suggested(schema, "periodically_resetting") is True result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"source": input_sensor2_entity_id}, + user_input={"source": input_sensor2_entity_id, "periodically_resetting": False}, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { @@ -188,6 +245,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": False, "source": input_sensor2_entity_id, "tariffs": "", } @@ -198,6 +256,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": False, "source": input_sensor2_entity_id, "tariffs": "", } diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index ad4fc5e6e9..5c8d8d4253 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -186,6 +186,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["peak", "offpeak"], }, @@ -202,6 +203,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: "name": "Energy bill2", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["peak", "offpeak"], }, @@ -413,6 +415,7 @@ async def test_setup_and_remove_config_entry( "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": tariffs, }, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index c56010e36e..d84099b4d6 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) +from homeassistant.components.utility_meter import DEFAULT_OFFSET from homeassistant.components.utility_meter.const import ( ATTR_VALUE, DAILY, @@ -24,9 +25,11 @@ from homeassistant.components.utility_meter.const import ( ) from homeassistant.components.utility_meter.sensor import ( ATTR_LAST_RESET, + ATTR_LAST_VALID_STATE, ATTR_STATUS, COLLECTING, PAUSED, + UtilityMeterSensor, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -50,7 +53,7 @@ from tests.common import ( @pytest.fixture(autouse=True) -def set_utc(hass): +def set_utc(hass: HomeAssistant): """Set timezone to UTC.""" hass.config.set_time_zone("UTC") @@ -77,6 +80,7 @@ def set_utc(hass): "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak"], }, @@ -272,6 +276,7 @@ async def test_not_unique_tariffs(hass: HomeAssistant, yaml_config) -> None: "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak"], }, @@ -430,6 +435,7 @@ async def test_entity_name(hass: HomeAssistant, yaml_config, entity_id, name) -> "name": "Energy meter", "net_consumption": True, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -439,6 +445,7 @@ async def test_entity_name(hass: HomeAssistant, yaml_config, entity_id, name) -> "name": "Gas meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.gas", "tariffs": [], }, @@ -516,6 +523,7 @@ async def test_device_class( "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak", "superpeak"], }, @@ -552,6 +560,7 @@ async def test_restore_state( "native_unit_of_measurement": "kWh", "last_reset": last_reset, "last_period": "7", + "last_valid_state": "None", "status": "paused", }, ), @@ -562,6 +571,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: PAUSED, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -571,6 +581,7 @@ async def test_restore_state( "decimal_str": "3", }, "native_unit_of_measurement": "kWh", + "last_valid_state": "None", }, ), ( @@ -580,6 +591,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: COLLECTING, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -589,6 +601,7 @@ async def test_restore_state( "decimal_str": "3f", }, "native_unit_of_measurement": "kWh", + "last_valid_state": "None", }, ), ( @@ -598,6 +611,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: COLLECTING, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -625,15 +639,18 @@ async def test_restore_state( assert state.state == "3" assert state.attributes.get("status") == PAUSED assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get("last_valid_state") == "None" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_midpeak") assert state.state == "5" + assert state.attributes.get("last_valid_state") == "None" state = hass.states.get("sensor.energy_bill_offpeak") assert state.state == "6" assert state.attributes.get("status") == COLLECTING assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get("last_valid_state") == "None" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_superpeak") @@ -675,6 +692,7 @@ async def test_restore_state( "name": "Energy bill", "net_consumption": True, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -829,6 +847,7 @@ async def test_non_net_consumption( "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -884,7 +903,7 @@ async def test_delta_values( force_update=True, ) await hass.async_block_till_done() - assert "Invalid adjustment of None" in caplog.text + assert "Invalid state None" in caplog.text now += timedelta(seconds=30) with freeze_time(now): @@ -918,6 +937,272 @@ async def test_delta_values( assert state.state == "9" +@pytest.mark.parametrize( + ("yaml_config", "config_entry_config"), + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "periodically_resetting": False, + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "source": "sensor.energy", + "tariffs": [], + }, + ), + ), +) +async def test_non_periodically_resetting( + hass: HomeAssistant, yaml_config, config_entry_config +) -> None: + """Test utility meter "non periodically resetting" mode.""" + # Home assistant is not runnit yet + hass.state = CoreState.not_running + + now = dt_util.utcnow() + with freeze_time(now): + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + version=2, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = config_entry_config["source"] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, 1, {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.attributes.get("status") == PAUSED + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + 3, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "2" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "2" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + 6, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "5" + assert state.attributes.get("last_valid_state") == "6" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + hass.states.async_set( + entity_id, + 9, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "8" + assert state.attributes.get("last_valid_state") == "9" + assert state.attributes.get("status") == COLLECTING + + +@pytest.mark.parametrize( + ("yaml_config", "config_entry_config"), + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "periodically_resetting": False, + "tariffs": ["low", "high"], + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "source": "sensor.energy", + "tariffs": ["low", "high"], + }, + ), + ), +) +async def test_non_periodically_resetting_meter_with_tariffs( + hass: HomeAssistant, yaml_config, config_entry_config +) -> None: + """Test test_non_periodically_resetting_meter_with_tariffs.""" + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + version=2, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = config_entry_config["source"] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + hass.states.async_set( + entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state is not None + assert state.state == "0" + assert state.attributes.get("status") == COLLECTING + assert state.attributes.get("last_valid_state") == "2" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "0" + assert state.attributes.get("status") == PAUSED + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + + now = dt_util.utcnow() + timedelta(seconds=10) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 3, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state is not None + assert state.state == "1" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "0" + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.energy_bill", "option": "high"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + state = hass.states.get("sensor.energy_bill_high") + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == COLLECTING + + now = dt_util.utcnow() + timedelta(seconds=20) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 6, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state is not None + assert state.state == "1" + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "3" + assert state.attributes.get("last_valid_state") == "6" + assert state.attributes.get("status") == COLLECTING + + def gen_config(cycle, offset=None): """Generate configuration.""" config = { @@ -932,7 +1217,9 @@ def gen_config(cycle, offset=None): return config -async def _test_self_reset(hass, config, start_time, expect_reset=True): +async def _test_self_reset( + hass: HomeAssistant, config, start_time, expect_reset=True +) -> None: """Test energy sensor self reset.""" now = dt_util.parse_datetime(start_time) with freeze_time(now): @@ -1142,3 +1429,27 @@ async def test_bad_offset(hass: HomeAssistant) -> None: assert not await async_setup_component( hass, DOMAIN, gen_config("monthly", timedelta(days=31)) ) + + +def test_calculate_adjustment_invalid_new_state( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that calculate_adjustment method returns None if the new state is invalid.""" + mock_sensor = UtilityMeterSensor( + cron_pattern=None, + delta_values=False, + meter_offset=DEFAULT_OFFSET, + meter_type=DAILY, + name="Test utility meter", + net_consumption=False, + parent_meter="sensor.test", + periodically_resetting=True, + unique_id="test_utility_meter", + source_entity="sensor.test", + tariff=None, + tariff_entity=None, + ) + + new_state: State = State(entity_id="sensor.test", state="unknown") + assert mock_sensor.calculate_adjustment(None, new_state) is None + assert "Invalid state unknown" in caplog.text From d907bd2ca3980f9f065273c0591a925e6b22c393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 28 Mar 2023 17:09:59 +0200 Subject: [PATCH 0270/1165] Add connected relayer region to system health (#90410) --- homeassistant/components/cloud/client.py | 11 +++++++++++ homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/cloud/strings.json | 1 + .../components/cloud/system_health.py | 1 + homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_client.py | 19 +++++++++++++++++++ tests/components/cloud/test_system_health.py | 4 +++- 9 files changed, 39 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 08d4364424..900779f6b0 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -47,6 +47,7 @@ class CloudClient(Interface): self._google_config: google_config.CloudGoogleConfig | None = None self._alexa_config_init_lock = asyncio.Lock() self._google_config_init_lock = asyncio.Lock() + self._relayer_region: str | None = None @property def base_path(self) -> Path: @@ -84,6 +85,11 @@ class CloudClient(Interface): """Return true if we want start a remote connection.""" return self._prefs.remote_enabled + @property + def relayer_region(self) -> str | None: + """Return the connected relayer region.""" + return self._relayer_region + async def get_alexa_config(self) -> alexa_config.CloudAlexaConfig: """Return Alexa config.""" if self._alexa_config is None: @@ -256,6 +262,11 @@ class CloudClient(Interface): "headers": {"Content-Type": response.content_type}, } + async def async_system_message(self, payload: dict[Any, Any] | None) -> None: + """Handle system messages.""" + if payload and (region := payload.get("region")): + self._relayer_region = region + async def async_cloudhooks_update(self, data: dict[str, dict[str, str]]) -> None: """Update local list of cloudhooks.""" await self._prefs.async_update(cloudhooks=data) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7bd4a822fb..2bff400366 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.62.0"] + "requirements": ["hass-nabucasa==0.63.1"] } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index e437fca9ed..432a4db0f7 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -5,6 +5,7 @@ "can_reach_cloud": "Reach Home Assistant Cloud", "can_reach_cloud_auth": "Reach Authentication Server", "relayer_connected": "Relayer Connected", + "relayer_region": "Relayer Region", "remote_connected": "Remote Connected", "remote_enabled": "Remote Enabled", "remote_server": "Remote Server", diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 9f836114b3..b1f1774aa4 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -28,6 +28,7 @@ async def system_health_info(hass): if cloud.is_logged_in: data["subscription_expiration"] = cloud.expiration_date data["relayer_connected"] = cloud.is_connected + data["relayer_region"] = client.relayer_region data["remote_enabled"] = client.prefs.remote_enabled data["remote_connected"] = cloud.remote.is_connected data["alexa_enabled"] = client.prefs.alexa_enabled diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 518bea69fb..dea105f296 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ cryptography==40.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 ha-av==10.0.0 -hass-nabucasa==0.62.0 +hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230309.1 diff --git a/requirements_all.txt b/requirements_all.txt index 46599fbafe..27a50ca564 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -868,7 +868,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.62.0 +hass-nabucasa==0.63.1 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b41d46936..f4a9437f9b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -666,7 +666,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.62.0 +hass-nabucasa==0.63.1 # homeassistant.components.conversation hassil==1.0.6 diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 9f463803b8..b7bfed53aa 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -312,3 +312,22 @@ async def test_login_recovers_bad_internet( await hass.async_block_till_done() assert len(client._alexa_config.async_enable_proactive_mode.mock_calls) == 2 + + +async def test_system_msg(hass: HomeAssistant) -> None: + """Test system msg.""" + with patch("hass_nabucasa.Cloud.initialize"): + setup = await async_setup_component(hass, "cloud", {"cloud": {}}) + assert setup + cloud = hass.data["cloud"] + + assert cloud.client.relayer_region is None + + response = await cloud.client.async_system_message( + { + "region": "xx-earth-616", + } + ) + + assert response is None + assert cloud.client.relayer_region == "xx-earth-616" diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index b2b74d8929..96b87936da 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -36,11 +36,12 @@ async def test_cloud_system_health( expiration_date=now, is_connected=True, client=Mock( + relayer_region="xx-earth-616", prefs=Mock( remote_enabled=True, alexa_enabled=True, google_enabled=False, - ) + ), ), ) @@ -54,6 +55,7 @@ async def test_cloud_system_health( "logged_in": True, "subscription_expiration": now, "relayer_connected": True, + "relayer_region": "xx-earth-616", "remote_enabled": True, "remote_connected": False, "remote_server": "us-west-1", From 0a51914740c8fc4810f8cd4625c62e3d01b1d548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Huryn?= Date: Tue, 28 Mar 2023 17:11:48 +0200 Subject: [PATCH 0271/1165] Blebox cover tilt (#85515) * feature: added tilt for shutterBox * test: include tilt in tests --- homeassistant/components/blebox/cover.py | 17 ++++++++ tests/components/blebox/test_cover.py | 49 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 80e2fbd30e..658a9bc30c 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -8,6 +8,7 @@ import blebox_uniapi.cover from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityFeature, @@ -67,6 +68,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): self._attr_supported_features = ( position | stop | CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) + if feature.has_tilt: + self._attr_supported_features = ( + self._attr_supported_features | CoverEntityFeature.SET_TILT_POSITION + ) @property def current_cover_position(self) -> int | None: @@ -77,6 +82,12 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): return None if position is None else 100 - position + @property + def current_cover_tilt_position(self) -> int | None: + """Return the current tilt of shutter.""" + position = self._feature.tilt_current + return None if position is None else 100 - position + @property def is_opening(self) -> bool | None: """Return whether cover is opening.""" @@ -110,6 +121,12 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): """Stop the cover.""" await self._feature.async_stop() + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Set the tilt position.""" + + position = kwargs[ATTR_TILT_POSITION] + await self._feature.async_set_tilt_position(100 - position) + def _is_state(self, state_name) -> bool | None: value = BLEBOX_TO_HASS_COVER_STATES[self._feature.state] return None if value is None else value == state_name diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index ce7006951f..d0a10cb5bd 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -7,7 +7,9 @@ import pytest from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, @@ -21,6 +23,7 @@ from homeassistant.const import ( SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_UNKNOWN, ) @@ -43,8 +46,10 @@ def shutterbox_fixture(): full_name="shutterBox-position", device_class="shutter", current=None, + tilt_current=None, state=None, has_stop=True, + has_tilt=True, is_slider=True, ) product = feature.product @@ -420,3 +425,47 @@ async def test_closed_state(feature, hass: HomeAssistant) -> None: feature_mock.async_update = AsyncMock(side_effect=initial_update) await async_setup_entity(hass, entity_id) assert hass.states.get(entity_id).state == STATE_CLOSED + + +async def test_tilt_position(shutterbox, hass): + """Test tilt capability is available.""" + + feature_mock, entity_id = shutterbox + + def tilt_update(): + feature_mock.tilt_current = 90 + + feature_mock.async_update = AsyncMock(side_effect=tilt_update) + + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 + + +async def test_set_tilt_position(shutterbox, hass): + """Test tilt position setting.""" + + feature_mock, entity_id = shutterbox + + def initial_update(): + feature_mock.state = 3 + + def set_tilt(tilt_position): + assert tilt_position == 20 + feature_mock.state = 1 + + feature_mock.async_update = AsyncMock(side_effect=initial_update) + feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt) + + await async_setup_entity(hass, entity_id) + assert hass.states.get(entity_id).state == STATE_CLOSED + + feature_mock.async_update = AsyncMock() + await hass.services.async_call( + "cover", + SERVICE_SET_COVER_TILT_POSITION, + {"entity_id": entity_id, ATTR_TILT_POSITION: 80}, + blocking=True, + ) + assert hass.states.get(entity_id).state == STATE_OPENING From 9fecdddf0114079676c45d1e4c593473ee11e016 Mon Sep 17 00:00:00 2001 From: DerEnderKeks Date: Tue, 28 Mar 2023 17:14:52 +0200 Subject: [PATCH 0272/1165] Don't use force_update for Tasmota sensors (#85943) * fix: don't use force_update for Tasmota sensors * Update binary_sensor.py * Update test_binary_sensor.py * Update test_sensor.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/tasmota/binary_sensor.py | 8 ++++---- homeassistant/components/tasmota/sensor.py | 1 - tests/components/tasmota/test_binary_sensor.py | 2 +- tests/components/tasmota/test_sensor.py | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 2bc23655a2..d84087b313 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -58,17 +58,17 @@ class TasmotaBinarySensor( ): """Representation a Tasmota binary sensor.""" - _attr_force_update = True + _delay_listener: Callable | None = None + _on_off_state: bool | None = None _tasmota_entity: tasmota_switch.TasmotaSwitch def __init__(self, **kwds: Any) -> None: """Initialize the Tasmota binary sensor.""" - self._delay_listener: Callable | None = None - self._on_off_state: bool | None = None - super().__init__( **kwds, ) + if self._tasmota_entity.off_delay is not None: + self._attr_force_update = True async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 61c03b707c..ddcdb3e8c2 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -268,7 +268,6 @@ async def async_setup_entry( class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): """Representation of a Tasmota sensor.""" - _attr_force_update = True _tasmota_entity: tasmota_sensor.TasmotaSensor def __init__(self, **kwds: Any) -> None: diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 8b3607bb9f..6a82a0f0e7 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -106,7 +106,7 @@ async def test_controlling_state_via_mqtt( entity = hass.data["entity_components"]["binary_sensor"].get_entity( "binary_sensor.tasmota_binary_sensor_1" ) - assert entity.force_update + assert not entity.force_update async def test_controlling_state_via_mqtt_switchname( diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 3a715ea95e..7eee8fcbe7 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -545,7 +545,7 @@ async def test_status_sensor_state_via_mqtt( entity = hass.data["entity_components"]["sensor"].get_entity( "sensor.tasmota_status" ) - assert entity.force_update + assert not entity.force_update @pytest.mark.parametrize("status_sensor_disabled", [False]) From 4b3c1f2800ee18e3c3da1fff1e138daf8c79abd0 Mon Sep 17 00:00:00 2001 From: Dmitry Vlasov Date: Tue, 28 Mar 2023 18:24:19 +0300 Subject: [PATCH 0273/1165] Update zwave-me-ws version to 0.3.6 (#90233) --- homeassistant/components/zwave_me/__init__.py | 35 ++++++++++++++++++- .../components/zwave_me/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index 346831b34d..1740820d0b 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -50,6 +50,8 @@ class ZWaveMeController: self.zwave_api = ZWaveMe( on_device_create=self.on_device_create, on_device_update=self.on_device_update, + on_device_remove=self.on_device_unavailable, + on_device_destroy=self.on_device_destroy, on_new_device=self.add_device, token=self.config.data[CONF_TOKEN], url=self.config.data[CONF_URL], @@ -82,6 +84,14 @@ class ZWaveMeController: """Send signal to update device.""" dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{new_info.id}", new_info) + def on_device_unavailable(self, device_id: str) -> None: + """Send signal to set device unavailable.""" + dispatcher_send(self._hass, f"ZWAVE_ME_UNAVAILABLE_{device_id}") + + def on_device_destroy(self, device_id: str) -> None: + """Send signal to destroy device.""" + dispatcher_send(self._hass, f"ZWAVE_ME_DESTROY_{device_id}") + def remove_stale_devices(self, registry: dr.DeviceRegistry): """Remove old-format devices in the registry.""" for device_id in self.device_ids: @@ -133,10 +143,33 @@ class ZWaveMeEntity(Entity): self.hass, f"ZWAVE_ME_INFO_{self.device.id}", self.get_new_data ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"ZWAVE_ME_UNAVAILABLE_{self.device.id}", + self.set_unavailable_status, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"ZWAVE_ME_DESTROY_{self.device.id}", self.delete_entity + ) + ) @callback - def get_new_data(self, new_data): + def get_new_data(self, new_data: ZWaveMeData) -> None: """Update info in the HAss.""" self.device = new_data self._attr_available = not new_data.isFailed self.async_write_ha_state() + + @callback + def set_unavailable_status(self): + """Update status in the HAss.""" + self._attr_available = False + self.async_write_ha_state() + + @callback + def delete_entity(self) -> None: + """Remove this entity.""" + self.hass.async_create_task(self.async_remove(force_remove=True)) diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 6339015968..388a8c2c1d 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave_me_ws==0.3.1", "url-normalize==1.4.3"], + "requirements": ["zwave_me_ws==0.3.6", "url-normalize==1.4.3"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 27a50ca564..4c5dec6d8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2731,4 +2731,4 @@ zm-py==0.5.2 zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me -zwave_me_ws==0.3.1 +zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4a9437f9b..5ab64c066c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1956,4 +1956,4 @@ zigpy==0.53.2 zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me -zwave_me_ws==0.3.1 +zwave_me_ws==0.3.6 From 89a3c304c2b9216510600ed66e2a064fb161e76d Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 28 Mar 2023 18:39:10 +0200 Subject: [PATCH 0274/1165] Refactor ZHA binary sensors to read from zigpy cache (#89481) * Construct binary sensor state from zigpy cache (WIP) * Workaround zha-quirks issue where "MotionWithReset" quirks don't update attribute cache (WIP) zha-quirks currently has an issue where the ZONE_STATE attribute is updated (when the zone_STATUS changes). https://github.com/zigpy/zha-device-handlers/pull/2231 is a proper fix for this. For now, we just update the attribute cache when we get the "zone status update notification" command. This wasn't noticed before, as the "attribute report signal" was sent from the `cluster_command()` method and the used the provided attribute (in the signal) to update the `_state` value in the binary sensor class. As we just tell HA to write state again when we get an attribute report now, the ZONE_STATUS attribute is read now (and needs to be correct). * Use parse() method of main class for IasZone entity (with stripped bits) * Change wording in comment, remove explicitly sending attr signal (This comment should be removed/changed later anyway) * Remove note * Get zone_status attribute id with zigpy * Remove `security.` prefix for `IasZone` import `AceCluster` was already directly imported and `IasZone` is too now for getting the attribute id * Store full zone status attribute in cache * Check that non-alarm bits are ignored in IasZone sensor test * Re-enable occupancy binary sensor test This test seems to work fine and I don't see any reason why it was commented out for a while * Fix cached read mix-up for `zone_status`/`zone_state` This allows cached reads for `zone_state` (enrolled or not), but forces a new read for `zone_status` (alarm or not). --- homeassistant/components/zha/binary_sensor.py | 37 ++++++------------- .../components/zha/core/channels/security.py | 20 +++++----- tests/components/zha/test_binary_sensor.py | 7 +++- 3 files changed, 28 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index b6a0af8e45..9c2fb49de6 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_ON, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -76,34 +76,23 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) - @callback - def async_restore_last_state(self, last_state): - """Restore previous state.""" - super().async_restore_last_state(last_state) - self._state = last_state.state == STATE_ON - @property def is_on(self) -> bool: """Return True if the switch is on based on the state machine.""" - if self._state is None: + raw_state = self._channel.cluster.get(self.SENSOR_ATTR) + if raw_state is None: return False - return self._state + return self.parse(raw_state) @callback def async_set_state(self, attr_id, attr_name, value): """Set the state.""" - if self.SENSOR_ATTR is None or attr_name != self.SENSOR_ATTR: - return - self._state = bool(value) self.async_write_ha_state() - async def async_update(self) -> None: - """Attempt to retrieve on off state from the binary sensor.""" - await super().async_update() - attribute = getattr(self._channel, "value_attribute", "on_off") - attr_value = await self._channel.get_attribute_value(attribute) - if attr_value is not None: - self._state = attr_value + @staticmethod + def parse(value: bool | int) -> bool: + """Parse the raw attribute into a bool state.""" + return bool(value) @MULTI_MATCH(channel_names=CHANNEL_ACCELEROMETER) @@ -167,12 +156,10 @@ class IASZone(BinarySensor): """Return device class from component DEVICE_CLASSES.""" return CLASS_MAPPING.get(self._channel.cluster.get("zone_type")) - async def async_update(self) -> None: - """Attempt to retrieve on off state from the binary sensor.""" - await super().async_update() - value = await self._channel.get_attribute_value("zone_status") - if value is not None: - self._state = value & 3 + @staticmethod + def parse(value: bool | int) -> bool: + """Parse the raw attribute into a bool state.""" + return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state @MULTI_MATCH( diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index b5a8d5d8cf..404e4a8d25 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any from zigpy.exceptions import ZigbeeException import zigpy.zcl from zigpy.zcl.clusters import security -from zigpy.zcl.clusters.security import IasAce as AceCluster +from zigpy.zcl.clusters.security import IasAce as AceCluster, IasZone from homeassistant.core import callback @@ -332,21 +332,22 @@ class IasWd(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(security.IasZone.cluster_id) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(IasZone.cluster_id) class IASZoneChannel(ZigbeeChannel): """Channel for the IASZone Zigbee cluster.""" - ZCL_INIT_ATTRS = {"zone_status": True, "zone_state": False, "zone_type": True} + ZCL_INIT_ATTRS = {"zone_status": False, "zone_state": True, "zone_type": True} @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" if command_id == 0: - state = args[0] & 3 - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 2, "zone_status", state + zone_status = args[0] + # update attribute cache with new zone status + self.cluster.update_attribute( + IasZone.attributes_by_name["zone_status"].id, zone_status ) - self.debug("Updated alarm state: %s", state) + self.debug("Updated alarm state: %s", zone_status) elif command_id == 1: self.debug("Enroll requested") res = self._cluster.enroll_response(0, 0) @@ -389,11 +390,10 @@ class IASZoneChannel(ZigbeeChannel): @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" - if attrid == 2: - value = value & 3 + if attrid == IasZone.attributes_by_name["zone_status"].id: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, - self.cluster.attributes.get(attrid, [attrid])[0], + "zone_status", value, ) diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 58264bf666..d633e9173e 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -75,12 +75,17 @@ async def async_test_iaszone_on_off(hass, cluster, entity_id): await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF + # check that binary sensor remains off when non-alarm bits change + cluster.listener_event("cluster_command", 1, 0, [0b1111111100]) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_OFF + @pytest.mark.parametrize( ("device", "on_off_test", "cluster_name", "reporting"), [ (DEVICE_IAS, async_test_iaszone_on_off, "ias_zone", (0,)), - # (DEVICE_OCCUPANCY, async_test_binary_sensor_on_off, "occupancy", (1,)), + (DEVICE_OCCUPANCY, async_test_binary_sensor_on_off, "occupancy", (1,)), ], ) async def test_binary_sensor( From 9ccd43e5f1fe6e3e01a3f61d77475dc27e6ee5bc Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Tue, 28 Mar 2023 09:57:24 -0700 Subject: [PATCH 0275/1165] Add DataUpdateCoordinator to the Todoist integration (#89836) Co-authored-by: Franck Nijhof --- homeassistant/components/todoist/calendar.py | 28 ++++++++++------- .../components/todoist/coordinator.py | 31 +++++++++++++++++++ tests/components/todoist/test_calendar.py | 24 ++++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/todoist/coordinator.py diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 645fea865e..c3e8f61fcc 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -23,6 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt from .const import ( @@ -54,6 +55,7 @@ from .const import ( START, SUMMARY, ) +from .coordinator import TodoistCoordinator from .types import CalData, CustomProject, ProjectData, TodoistEvent _LOGGER = logging.getLogger(__name__) @@ -117,6 +119,8 @@ async def async_setup_platform( project_id_lookup = {} api = TodoistAPIAsync(token) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api) + await coordinator.async_config_entry_first_refresh() # Setup devices: # Grab all projects. @@ -131,7 +135,7 @@ async def async_setup_platform( # Project is an object, not a dict! # Because of that, we convert what we need to a dict. project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id} - project_devices.append(TodoistProjectEntity(project_data, labels, api)) + project_devices.append(TodoistProjectEntity(coordinator, project_data, labels)) # Cache the names so we can easily look up name->ID. project_id_lookup[project.name.lower()] = project.id @@ -157,9 +161,9 @@ async def async_setup_platform( # Create the custom project and add it to the devices array. project_devices.append( TodoistProjectEntity( + coordinator, {"id": None, "name": extra_project["name"]}, labels, - api, due_date_days=project_due_date, whitelisted_labels=project_label_filter, whitelisted_projects=project_id_filter, @@ -267,23 +271,24 @@ async def async_setup_platform( ) -class TodoistProjectEntity(CalendarEntity): +class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity): """A device for getting the next Task from a Todoist Project.""" def __init__( self, + coordinator: TodoistCoordinator, data: ProjectData, labels: list[Label], - api: TodoistAPIAsync, due_date_days: int | None = None, whitelisted_labels: list[str] | None = None, whitelisted_projects: list[str] | None = None, ) -> None: """Create the Todoist Calendar Entity.""" + super().__init__(coordinator=coordinator) self.data = TodoistProjectData( data, labels, - api, + coordinator, due_date_days=due_date_days, whitelisted_labels=whitelisted_labels, whitelisted_projects=whitelisted_projects, @@ -306,6 +311,7 @@ class TodoistProjectEntity(CalendarEntity): async def async_update(self) -> None: """Update all Todoist Calendars.""" + await super().async_update() await self.data.async_update() # Set Todoist-specific data that can't easily be grabbed self._cal_data["all_tasks"] = [ @@ -373,7 +379,7 @@ class TodoistProjectData: self, project_data: ProjectData, labels: list[Label], - api: TodoistAPIAsync, + coordinator: TodoistCoordinator, due_date_days: int | None = None, whitelisted_labels: list[str] | None = None, whitelisted_projects: list[str] | None = None, @@ -381,7 +387,7 @@ class TodoistProjectData: """Initialize a Todoist Project.""" self.event: TodoistEvent | None = None - self._api = api + self._coordinator = coordinator self._name = project_data[CONF_NAME] # If no ID is defined, fetch all tasks. self._id = project_data.get(CONF_ID) @@ -569,8 +575,8 @@ class TodoistProjectData: self, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all tasks in a specific time frame.""" + tasks = self._coordinator.data if self._id is None: - tasks = await self._api.get_tasks() project_task_data = [ task for task in tasks @@ -578,7 +584,7 @@ class TodoistProjectData: or task.project_id in self._project_id_whitelist ] else: - project_task_data = await self._api.get_tasks(project_id=self._id) + project_task_data = [task for task in tasks if task.project_id == self._id] events = [] for task in project_task_data: @@ -607,8 +613,8 @@ class TodoistProjectData: async def async_update(self) -> None: """Get the latest data.""" + tasks = self._coordinator.data if self._id is None: - tasks = await self._api.get_tasks() project_task_data = [ task for task in tasks @@ -616,7 +622,7 @@ class TodoistProjectData: or task.project_id in self._project_id_whitelist ] else: - project_task_data = await self._api.get_tasks(project_id=self._id) + project_task_data = [task for task in tasks if task.project_id == self._id] # If we have no data, we can just return right away. if not project_task_data: diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py new file mode 100644 index 0000000000..b573d1d112 --- /dev/null +++ b/homeassistant/components/todoist/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for the Todoist component.""" +from datetime import timedelta +import logging + +from todoist_api_python.api_async import TodoistAPIAsync +from todoist_api_python.models import Task + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + + +class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): + """Coordinator for updating task data from Todoist.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + update_interval: timedelta, + api: TodoistAPIAsync, + ) -> None: + """Initialize the Todoist coordinator.""" + super().__init__(hass, logger, name="Todoist", update_interval=update_interval) + self.api = api + + async def _async_update_data(self) -> list[Task]: + """Fetch tasks from the Todoist API.""" + try: + return await self.api.get_tasks() + except Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 9c0680d144..4f792b3cc0 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -132,6 +132,30 @@ async def test_update_entity_for_custom_project_with_labels_on( assert state.state == "on" +@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +async def test_failed_coordinator_update(todoist_api, hass: HomeAssistant, api) -> None: + """Test a failed data coordinator update is handled correctly.""" + api.get_tasks.side_effect = Exception("API error") + todoist_api.return_value = api + + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + "custom_projects": [{"name": "All projects", "labels": ["Label1"]}], + } + }, + ) + await hass.async_block_till_done() + + await async_update_entity(hass, "calendar.all_projects") + state = hass.states.get("calendar.all_projects") + assert state is None + + @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") async def test_calendar_custom_project_unique_id( todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry From d21433b6af99a2223c7895b0300d5e9ae9018238 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 08:50:10 -1000 Subject: [PATCH 0276/1165] Ensure filters are generated inside the lambda locks (#90418) --- .../components/logbook/queries/__init__.py | 7 +----- .../components/logbook/queries/all.py | 25 ++++++++----------- homeassistant/components/recorder/filters.py | 14 ++++++----- .../components/recorder/history/legacy.py | 8 +++--- .../components/recorder/history/modern.py | 10 +++++--- tests/components/recorder/test_filters.py | 24 ++++++++++++++++++ 6 files changed, 54 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 0172700df4..b83f7a4428 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -34,16 +34,11 @@ def statement_for_request( # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: context_id_bin = ulid_to_bytes_or_none(context_id) - states_entity_filter = ( - filters.states_metadata_entity_filter() if filters else None - ) - events_entity_filter = filters.events_entity_filter() if filters else None return all_stmt( start_day, end_day, event_types, - states_entity_filter, - events_entity_filter, + filters, context_id_bin, ) diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index 8c37bf22da..70214fbb04 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -2,7 +2,6 @@ from __future__ import annotations from sqlalchemy import lambda_stmt -from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select @@ -11,6 +10,7 @@ from homeassistant.components.recorder.db_schema import ( Events, States, ) +from homeassistant.components.recorder.filters import Filters from .common import apply_states_filters, select_events_without_states, select_states @@ -19,8 +19,7 @@ def all_stmt( start_day: float, end_day: float, event_types: tuple[str, ...], - states_entity_filter: ColumnElement | None = None, - events_entity_filter: ColumnElement | None = None, + filters: Filters | None, context_id_bin: bytes | None = None, ) -> StatementLambdaElement: """Generate a logbook query for all entities.""" @@ -36,19 +35,17 @@ def all_stmt( context_id_bin, # type:ignore[arg-type] ), ) - else: - if events_entity_filter is not None: - stmt += lambda s: s.where(events_entity_filter) - - if states_entity_filter is not None: - stmt += lambda s: s.union_all( + elif filters and filters.has_config: + stmt = stmt.add_criteria( + lambda q: q.filter(filters.events_entity_filter()).union_all( # type: ignore[union-attr] _states_query_for_all(start_day, end_day).where( - # https://github.com/python/mypy/issues/2608 - states_entity_filter # type:ignore[arg-type] + filters.states_metadata_entity_filter() # type: ignore[union-attr] ) - ) - else: - stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) + ), + track_on=[filters], + ) + else: + stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) stmt += lambda s: s.order_by(Events.time_fired_ts) return stmt diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 63eed2d145..de0929cf9f 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -125,8 +125,8 @@ class Filters: def _generate_filter_for_columns( self, columns: Iterable[Column], encoder: Callable[[Any], Any] - ) -> ColumnElement | None: - """Generate a filter from pre-comuted sets and pattern lists. + ) -> ColumnElement: + """Generate a filter from pre-computed sets and pattern lists. This must match exactly how homeassistant.helpers.entityfilter works. """ @@ -146,7 +146,9 @@ class Filters: # Case 1 - No filter # - All entities included if not have_include and not have_exclude: - return None + raise RuntimeError( + "No filter configuration provided, check has_config before calling this method." + ) # Case 2 - Only includes # - Entity listed in entities include: include @@ -193,7 +195,7 @@ class Filters: # - Otherwise: exclude return i_entities - def states_entity_filter(self) -> ColumnElement | None: + def states_entity_filter(self) -> ColumnElement: """Generate the States.entity_id filter query. This is no longer used except by the legacy queries. @@ -206,7 +208,7 @@ class Filters: # The type annotation should be improved so the type ignore can be removed return self._generate_filter_for_columns((States.entity_id,), _encoder) # type: ignore[arg-type] - def states_metadata_entity_filter(self) -> ColumnElement | None: + def states_metadata_entity_filter(self) -> ColumnElement: """Generate the StatesMeta.entity_id filter query.""" def _encoder(data: Any) -> Any: @@ -232,7 +234,7 @@ class Filters: (OLD_ENTITY_ID_IN_EVENT == JSON_NULL) | OLD_ENTITY_ID_IN_EVENT.is_(None) ), # Needs https://github.com/bdraco/home-assistant/commit/bba91945006a46f3a01870008eb048e4f9cbb1ef - self._generate_filter_for_columns( # type: ignore[union-attr] + self._generate_filter_for_columns( (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder # type: ignore[arg-type] ).self_group(), ) diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index e51b1a2568..c33825a767 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -306,9 +306,8 @@ def _significant_states_stmt( else: stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_entity_filter() stmt = stmt.add_criteria( - lambda q: q.filter(entity_filter), track_on=[filters] + lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] ) if schema_version >= 31: @@ -713,8 +712,9 @@ def _get_states_for_all_stmt( ) stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_entity_filter() - stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) + stmt = stmt.add_criteria( + lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 22bfdc3ee9..f7d08c6bba 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -192,9 +192,9 @@ def _significant_states_stmt( else: stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_metadata_entity_filter() stmt = stmt.add_criteria( - lambda q: q.filter(entity_filter), track_on=[filters] + lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] + track_on=[filters], ) join_states_meta = True @@ -567,8 +567,10 @@ def _get_states_for_all_stmt( ) stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_metadata_entity_filter() - stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) + stmt = stmt.add_criteria( + lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] + track_on=[filters], + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) diff --git a/tests/components/recorder/test_filters.py b/tests/components/recorder/test_filters.py index 7f7d12364e..13a2a325f1 100644 --- a/tests/components/recorder/test_filters.py +++ b/tests/components/recorder/test_filters.py @@ -1,6 +1,9 @@ """The tests for recorder filters.""" +import pytest + from homeassistant.components.recorder.filters import ( + Filters, extract_include_exclude_filter_conf, merge_include_exclude_filters, ) @@ -132,3 +135,24 @@ def test_merge_include_exclude_filters() -> None: CONF_ENTITY_GLOBS: {"climate.*", "not_climate.*"}, }, } + + +async def test_an_empty_filter_raises() -> None: + """Test empty filter raises when not guarding with has_config.""" + filters = Filters() + assert not filters.has_config + with pytest.raises( + RuntimeError, + match="No filter configuration provided, check has_config before calling this method", + ): + filters.states_metadata_entity_filter() + with pytest.raises( + RuntimeError, + match="No filter configuration provided, check has_config before calling this method", + ): + filters.states_entity_filter() + with pytest.raises( + RuntimeError, + match="No filter configuration provided, check has_config before calling this method", + ): + filters.events_entity_filter() From 24d0d15f38a33b2ee8387d2ed45640b7f45663c4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 28 Mar 2023 21:02:43 +0200 Subject: [PATCH 0277/1165] Implement imap_content event for imap integration (#90242) --- .coveragerc | 3 - homeassistant/components/imap/coordinator.py | 111 ++++++- tests/components/imap/conftest.py | 104 +++++- tests/components/imap/const.py | 139 ++++++++ tests/components/imap/test_init.py | 323 +++++++++++++++++++ 5 files changed, 671 insertions(+), 9 deletions(-) create mode 100644 tests/components/imap/const.py create mode 100644 tests/components/imap/test_init.py diff --git a/.coveragerc b/.coveragerc index 82677177e6..4b831fc3d3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -518,9 +518,6 @@ omit = homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/iglo/light.py homeassistant/components/ihc/* - homeassistant/components/imap/__init__.py - homeassistant/components/imap/coordinator.py - homeassistant/components/imap/sensor.py homeassistant/components/imap_email_content/sensor.py homeassistant/components/incomfort/* homeassistant/components/insteon/binary_sensor.py diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 69f291df6e..76eb8e46f5 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping from datetime import timedelta +import email import logging from typing import Any @@ -11,7 +12,12 @@ from aioimaplib import AUTH, IMAP4_SSL, SELECTED, AioImapException import async_timeout from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONTENT_TYPE_TEXT_PLAIN, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,6 +29,8 @@ _LOGGER = logging.getLogger(__name__) BACKOFF_TIME = 10 +EVENT_IMAP = "imap_content" + async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" @@ -37,6 +45,70 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: return client +class ImapMessage: + """Class to parse an RFC822 email message.""" + + def __init__(self, raw_message: bytes) -> None: + """Initialize IMAP message.""" + self.email_message = email.message_from_bytes(raw_message) + + @property + def headers(self) -> dict[str, tuple[str,]]: + """Get the email headers.""" + header_base: dict[str, tuple[str,]] = {} + for key, value in self.email_message.items(): + header: tuple[str,] = (str(value),) + if header_base.setdefault(key, header) != header: + header_base[key] += header # type: ignore[assignment] + return header_base + + @property + def sender(self) -> str: + """Get the parsed message sender from the email.""" + return str(email.utils.parseaddr(self.email_message["From"])[1]) + + @property + def subject(self) -> str: + """Decode the message subject.""" + decoded_header = email.header.decode_header(self.email_message["Subject"]) + header = email.header.make_header(decoded_header) + return str(header) + + @property + def text(self) -> str: + """Get the message text from the email. + + Will look for text/plain or use text/html if not found. + """ + message_text = None + message_html = None + message_untyped_text = None + + for part in self.email_message.walk(): + if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: + if message_text is None: + message_text = part.get_payload() + elif part.get_content_type() == "text/html": + if message_html is None: + message_html = part.get_payload() + elif ( + part.get_content_type().startswith("text") + and message_untyped_text is None + ): + message_untyped_text = part.get_payload() + + if message_text is not None: + return message_text + + if message_html is not None: + return message_html + + if message_untyped_text is not None: + return message_untyped_text + + return self.email_message.get_payload() + + class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Base class for imap client.""" @@ -50,6 +122,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): ) -> None: """Initiate imap client.""" self.imap_client = imap_client + self._last_message_id: str | None = None super().__init__( hass, _LOGGER, @@ -65,8 +138,30 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): if self.imap_client is None: self.imap_client = await connect_to_server(self.config_entry.data) + async def _async_process_event(self, last_message_id: str) -> None: + """Send a event for the last message if the last message was changed.""" + response = await self.imap_client.fetch(last_message_id, "BODY.PEEK[]") + if response.result == "OK": + message = ImapMessage(response.lines[1]) + data = { + "server": self.config_entry.data[CONF_SERVER], + "username": self.config_entry.data[CONF_USERNAME], + "search": self.config_entry.data[CONF_SEARCH], + "folder": self.config_entry.data[CONF_FOLDER], + "text": message.text, + "sender": message.sender, + "subject": message.subject, + "headers": message.headers, + } + self.hass.bus.fire(EVENT_IMAP, data) + _LOGGER.debug( + "Message processed, sender: %s, subject: %s", + message.sender, + message.subject, + ) + async def _async_fetch_number_of_messages(self) -> int | None: - """Fetch number of messages.""" + """Fetch last message and messages count.""" await self._async_reconnect_if_needed() await self.imap_client.noop() result, lines = await self.imap_client.search( @@ -77,7 +172,17 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - return len(lines[0].split()) + count: int = len(message_ids := lines[0].split()) + last_message_id = ( + str(message_ids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) + if count + else None + ) + if count and last_message_id is not None: + self._last_message_id = last_message_id + await self._async_process_event(last_message_id) + + return count async def _cleanup(self, log_error: bool = False) -> None: """Close resources.""" diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py index bc82cf57d8..74176efab1 100644 --- a/tests/components/imap/conftest.py +++ b/tests/components/imap/conftest.py @@ -1,9 +1,13 @@ -"""Test the iamp config flow.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +"""Fixtures for imap tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from aioimaplib import AUTH, LOGOUT, NONAUTH, SELECTED, STARTED, Response import pytest +from .const import EMPTY_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -12,3 +16,97 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.imap.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def imap_has_capability() -> bool: + """Fixture to set the imap capabilities.""" + return True + + +@pytest.fixture +def imap_login_state() -> str: + """Fixture to set the imap state after login.""" + return AUTH + + +@pytest.fixture +def imap_select_state() -> str: + """Fixture to set the imap capabilities.""" + return SELECTED + + +@pytest.fixture +def imap_search() -> tuple[str, list[bytes]]: + """Fixture to set the imap search response.""" + return EMPTY_SEARCH_RESPONSE + + +@pytest.fixture +def imap_fetch() -> tuple[str, list[bytes | bytearray]]: + """Fixture to set the imap fetch response.""" + return TEST_FETCH_RESPONSE_TEXT_PLAIN + + +@pytest.fixture +def imap_pending_idle() -> bool: + """Fixture to set the imap pending idle feature.""" + return True + + +@pytest.fixture +async def mock_imap_protocol( + imap_search: tuple[str, list[bytes]], + imap_fetch: tuple[str, list[bytes | bytearray]], + imap_has_capability: bool, + imap_pending_idle: bool, + imap_login_state: str, + imap_select_state: str, +) -> Generator[MagicMock, None]: + """Mock the aioimaplib IMAP protocol handler.""" + + with patch( + "homeassistant.components.imap.coordinator.IMAP4_SSL", autospec=True + ) as imap_mock: + imap_mock = imap_mock.return_value + + async def login(user: str, password: str) -> Response: + """Mock imap login.""" + imap_mock.protocol.state = imap_login_state + if imap_login_state != AUTH: + return Response("BAD", []) + return Response("OK", [b"CAPABILITY IMAP4rev1 ...", b"Logged in"]) + + async def close() -> Response: + """Mock imap close the selected folder.""" + imap_mock.protocol.state = imap_login_state + return Response("OK", []) + + async def logout() -> Response: + """Mock imap logout.""" + imap_mock.protocol.state = LOGOUT + return Response("OK", []) + + async def select(mailbox: str = "INBOX") -> Response: + """Mock imap folder select.""" + imap_mock.protocol.state = imap_select_state + if imap_login_state != SELECTED: + return Response("BAD", []) + return Response("OK", []) + + async def wait_hello_from_server() -> None: + """Mock wait for hello.""" + imap_mock.protocol.state = NONAUTH + + imap_mock.has_pending_idle.return_value = imap_pending_idle + imap_mock.protocol = MagicMock() + imap_mock.protocol.state = STARTED + imap_mock.has_capability.return_value = imap_has_capability + imap_mock.login.side_effect = login + imap_mock.close.side_effect = close + imap_mock.logout.side_effect = logout + imap_mock.select.side_effect = select + imap_mock.search.return_value = Response(*imap_search) + imap_mock.fetch.return_value = Response(*imap_fetch) + imap_mock.wait_hello_from_server.side_effect = wait_hello_from_server + yield imap_mock diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py new file mode 100644 index 0000000000..68fab7d38c --- /dev/null +++ b/tests/components/imap/const.py @@ -0,0 +1,139 @@ +"""Constants for tests imap integration.""" + +TEST_MESSAGE = ( + b"Return-Path: \r\nDelivered-To: notify@example.com\r\n" + b"Received: from beta.example.com\r\n\tby beta with LMTP\r\n\t" + b"id eLp2M/GcHWQTLxQAho4UZQ\r\n\t(envelope-from )\r\n\t" + b"for ; Fri, 24 Mar 2023 13:52:01 +0100\r\n" + b"Received: from localhost (localhost [127.0.0.1])\r\n\t" + b"by beta.example.com (Postfix) with ESMTP id D0FFA61425\r\n\t" + b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" + b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" + b"MIME-Version: 1.0\r\n" + b"To: notify@example.com\r\n" + b"From: John Doe \r\n" + b"Subject: Test subject\r\n" +) + +TEST_CONTENT_TEXT_BARE = b"\r\n" b"Test body\r\n" b"\r\n" + +TEST_CONTENT_BINARY = ( + b"Content-Type: application/binary\r\n" + b"Content-Transfer-Encoding: base64\r\n" + b"\r\n" + b"VGVzdCBib2R5\r\n" +) + +TEST_CONTENT_TEXT_PLAIN = ( + b"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n" + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" +) + +TEST_CONTENT_TEXT_OTHER = ( + b"Content-Type: text/other; charset=UTF-8\r\n" + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" +) + +TEST_CONTENT_HTML = ( + b"Content-Type: text/html; charset=UTF-8\r\n" + b"Content-Transfer-Encoding: 7bit\r\n" + b"\r\n" + b"\r\n" + b" \r\n" + b' \r\n' + b" \r\n" + b" \r\n" + b"

Test body
\r\n" + b"

\r\n" + b" \r\n" + b"\r\n" + b"\r\n" +) + +TEST_CONTENT_MULTIPART = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + TEST_CONTENT_TEXT_PLAIN + + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + TEST_CONTENT_HTML + + b"--------------McwBciN2C0o3rWeF1tmFo2oI--\r\n" +) + +EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) +BAD_RESPONSE = ("BAD", [b"", b"Unexpected error"]) + +TEST_SEARCH_RESPONSE = ("OK", [b"1", b"Search completed (0.0001 + 0.000 secs)."]) + +TEST_FETCH_RESPONSE_TEXT_BARE = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_BARE)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_BARE), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_TEXT_PLAIN = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_TEXT_OTHER = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_OTHER)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_OTHER), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_BINARY = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_BINARY)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_BINARY), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_HTML = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_HTML)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_HTML), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_MULTIPART = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_MULTIPART)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_MULTIPART), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +RESPONSE_BAD = ("BAD", []) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py new file mode 100644 index 0000000000..ec9058830d --- /dev/null +++ b/tests/components/imap/test_init.py @@ -0,0 +1,323 @@ +"""Test the imap entry initialization.""" +import asyncio +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException, Response +import pytest + +from homeassistant.components.imap import DOMAIN +from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .const import ( + BAD_RESPONSE, + TEST_FETCH_RESPONSE_BINARY, + TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_TEXT_BARE, + TEST_FETCH_RESPONSE_TEXT_OTHER, + TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_SEARCH_RESPONSE, +) +from .test_config_flow import MOCK_CONFIG + +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_entry_startup_and_unload( + hass: HomeAssistant, mock_imap_protocol: MagicMock +) -> None: + """Test imap entry startup and unload with push and polling coordinator.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert await config_entry.async_unload(hass) + + +@pytest.mark.parametrize( + "effect", + [ + InvalidAuth, + InvalidFolder, + asyncio.TimeoutError, + ], +) +async def test_entry_startup_fails( + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + effect: Exception, +) -> None: + """Test imap entry startup fails on invalid auth or folder.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.imap.connect_to_server", + side_effect=effect, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) is False + + +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + "imap_fetch", + [ + TEST_FETCH_RESPONSE_TEXT_BARE, + TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_FETCH_RESPONSE_TEXT_OTHER, + TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_BINARY, + ], + ids=["bare", "plain", "other", "html", "multipart", "binary"], +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_receiving_message_successfully( + hass: HomeAssistant, mock_imap_protocol: MagicMock +) -> None: + """Test receiving a message successfully.""" + event_called = async_capture_events(hass, "imap_content") + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("imap_login_state", "success"), [(AUTH, True), (NONAUTH, False)] +) +async def test_initial_authentication_error( + hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool +) -> None: + """Test authentication error when starting the entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) == success + await hass.async_block_till_done() + + state = hass.states.get("sensor.imap_email_email_com") + assert (state is not None) == success + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("imap_select_state", "success"), [(AUTH, False), (SELECTED, True)] +) +async def test_initial_invalid_folder_error( + hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool +) -> None: + """Test invalid folder error when starting the entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) == success + await hass.async_block_till_done() + + state = hass.states.get("sensor.imap_email_email_com") + assert (state is not None) == success + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_late_authentication_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, +) -> None: + """Test authentication error handling after a search was failed.""" + + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = AioImapException( + "Something went wrong" + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + # Mock that the search fails, this will trigger + # that the connection will be restarted + # Then fail selecting the folder + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + mock_imap_protocol.login.side_effect = Response(*BAD_RESPONSE) + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + assert "Username or password incorrect, starting reauthentication" in caplog.text + + # we still should have an entity with an unavailable state + state = hass.states.get("sensor.imap_email_email_com") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_late_folder_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, +) -> None: + """Test invalid folder error handling after a search was failed. + + Asserting the IMAP push coordinator. + """ + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = AioImapException( + "Something went wrong" + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + # Mock that the search fails, this will trigger + # that the connection will be restarted + # Then fail selecting the folder + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + mock_imap_protocol.select.side_effect = Response(*BAD_RESPONSE) + + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + assert "Selected mailbox folder is invalid" in caplog.text + + # we still should have an entity with an unavailable state + state = hass.states.get("sensor.imap_email_email_com") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize( + "imap_close", + [ + AsyncMock(side_effect=AioImapException("Something went wrong")), + AsyncMock(side_effect=asyncio.TimeoutError), + ], + ids=["AioImapException", "TimeoutError"], +) +async def test_handle_cleanup_exception( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, + imap_close: Exception, +) -> None: + """Test handling an excepton during cleaning up.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity + assert state is not None + assert state.state == "0" + + # Fail cleaning up + mock_imap_protocol.close.side_effect = imap_close + + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert "Error while cleaning up imap connection" in caplog.text + + state = hass.states.get("sensor.imap_email_email_com") + + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) +@pytest.mark.parametrize( + "imap_wait_server_push_exception", + [ + AioImapException("Something went wrong"), + asyncio.TimeoutError, + ], + ids=["AioImapException", "TimeoutError"], +) +async def test_lost_connection_with_imap_push( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, + imap_wait_server_push_exception: AioImapException | asyncio.TimeoutError, +) -> None: + """Test error handling when the connection is lost.""" + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = imap_wait_server_push_exception + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Lost imap.server.com (will attempt to reconnect after 10 s)" in caplog.text + + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) +async def test_fetch_number_of_messages( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, +) -> None: + """Test _async_fetch_number_of_messages fails with push coordinator.""" + # Mock an error in waiting for a pushed update + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we wait for the backoff time + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + assert "Invalid response for search" in caplog.text + + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE From e9925f6062bb6e60a540de26d1f2367572462795 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 28 Mar 2023 22:46:59 +0200 Subject: [PATCH 0278/1165] Check webhook url is reachable in Reolink (#89585) Co-authored-by: Franck Nijhof --- homeassistant/components/reolink/host.py | 42 ++++++++++++++++--- homeassistant/components/reolink/strings.json | 6 ++- tests/components/reolink/conftest.py | 2 + tests/components/reolink/test_init.py | 19 ++++++++- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 9ba4809e90..1c0f97b6a2 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -54,7 +54,9 @@ class ReolinkHost: ) self.webhook_id: str | None = None - self._webhook_url: str | None = None + self._base_url: str = "" + self._webhook_url: str = "" + self._webhook_reachable: asyncio.Event = asyncio.Event() self._lost_subscription: bool = False @property @@ -138,6 +140,32 @@ class ReolinkHost: await self.subscribe() + _LOGGER.debug( + "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url + ) + try: + await asyncio.wait_for(self._webhook_reachable.wait(), timeout=15) + except asyncio.TimeoutError: + _LOGGER.debug( + "Did not receive initial ONVIF state on webhook '%s' after 15 seconds", + self._webhook_url, + ) + ir.async_create_issue( + self._hass, + DOMAIN, + "webhook_url", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="webhook_url", + translation_placeholders={ + "name": self._api.nvr_name, + "base_url": self._base_url, + "network_link": "https://my.home-assistant.io/redirect/network/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + if self._api.sw_version_update_required: ir.async_create_issue( self._hass, @@ -287,10 +315,10 @@ class ReolinkHost: ) try: - base_url = get_url(self._hass, prefer_external=False) + self._base_url = get_url(self._hass, prefer_external=False) except NoURLAvailableError: try: - base_url = get_url(self._hass, prefer_external=True) + self._base_url = get_url(self._hass, prefer_external=True) except NoURLAvailableError as err: self.unregister_webhook() raise ReolinkWebhookException( @@ -299,9 +327,9 @@ class ReolinkHost: ) from err webhook_path = webhook.async_generate_path(event_id) - self._webhook_url = f"{base_url}{webhook_path}" + self._webhook_url = f"{self._base_url}{webhook_path}" - if base_url.startswith("https"): + if self._base_url.startswith("https"): ir.async_create_issue( self._hass, DOMAIN, @@ -310,7 +338,7 @@ class ReolinkHost: severity=ir.IssueSeverity.WARNING, translation_key="https_webhook", translation_placeholders={ - "base_url": base_url, + "base_url": self._base_url, "network_link": "https://my.home-assistant.io/redirect/network/", }, ) @@ -337,6 +365,8 @@ class ReolinkHost: """Handle incoming webhook from Reolink for inbound messages and calls.""" _LOGGER.debug("Webhook '%s' called", webhook_id) + if not self._webhook_reachable.is_set(): + self._webhook_reachable.set() if not request.body_exists: _LOGGER.debug("Webhook '%s' triggered without payload", webhook_id) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 74759c12f9..50c561530e 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -41,7 +41,11 @@ "issues": { "https_webhook": { "title": "Reolink webhook URL uses HTTPS (SSL)", - "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`" + "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device" + }, + "webhook_url": { + "title": "Reolink webhook URL unreachable", + "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL." }, "enable_port": { "title": "Reolink port not enabled", diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 941a1ca7c8..be748ef2c4 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -39,6 +39,8 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None with patch( "homeassistant.components.reolink.host.webhook.async_register", return_value=True, + ), patch( + "homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock() ), patch( "homeassistant.components.reolink.host.Host", autospec=True ) as host_mock_class: diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 8849c7d52d..57d0dbd7cb 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,6 +1,7 @@ """Test the Reolink init.""" +import asyncio from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from reolink_aio.exceptions import ReolinkError @@ -99,6 +100,7 @@ async def test_no_repair_issue( issue_registry = ir.async_get(hass) assert (const.DOMAIN, "https_webhook") not in issue_registry.issues + assert (const.DOMAIN, "webhook_url") not in issue_registry.issues assert (const.DOMAIN, "enable_port") not in issue_registry.issues assert (const.DOMAIN, "firmware_update") not in issue_registry.issues @@ -138,6 +140,21 @@ async def test_port_repair_issue( assert (const.DOMAIN, "enable_port") in issue_registry.issues +async def test_webhook_repair_issue( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test repairs issue is raised when the webhook url is unreachable.""" + with patch( + "homeassistant.components.reolink.host.asyncio.Event.wait", + AsyncMock(side_effect=asyncio.TimeoutError()), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "webhook_url") in issue_registry.issues + + async def test_firmware_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock ) -> None: From 0ceee2b6c3a632caa25df8b76a6db5dfdb89fbb1 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 28 Mar 2023 16:48:27 -0400 Subject: [PATCH 0279/1165] Catch somecomfort error in Honeywell (#90425) --- homeassistant/components/honeywell/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 93c29446a5..ff54488228 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -63,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except ( aiosomecomfort.device.ConnectionError, aiosomecomfort.device.ConnectionTimeout, + aiosomecomfort.device.SomeComfortError, asyncio.TimeoutError, ) as ex: raise ConfigEntryNotReady( From 93e1cd8dd8064e2594ad10993cdd26d64523e5f5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 28 Mar 2023 22:50:25 +0200 Subject: [PATCH 0280/1165] Add header with parsed date to imap event data (#90422) --- homeassistant/components/imap/coordinator.py | 16 +++++++- tests/components/imap/const.py | 39 +++++++++++++++++++- tests/components/imap/test_init.py | 39 +++++++++++++++----- 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 76eb8e46f5..97432b9105 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -from datetime import timedelta +from datetime import datetime, timedelta import email import logging from typing import Any @@ -62,6 +62,19 @@ class ImapMessage: header_base[key] += header # type: ignore[assignment] return header_base + @property + def date(self) -> datetime | None: + """Get the date the email was sent.""" + # See https://www.rfc-editor.org/rfc/rfc2822#section-3.3 + date_str: str | None + if (date_str := self.email_message["Date"]) is None: + return None + # In some cases a timezone or comment is added in parenthesis after the date + # We want to strip that part to avoid parsing errors + return datetime.strptime( + date_str.split("(")[0].strip(), "%a, %d %b %Y %H:%M:%S %z" + ) + @property def sender(self) -> str: """Get the parsed message sender from the email.""" @@ -148,6 +161,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): "username": self.config_entry.data[CONF_USERNAME], "search": self.config_entry.data[CONF_SEARCH], "folder": self.config_entry.data[CONF_FOLDER], + "date": message.date, "text": message.text, "sender": message.sender, "subject": message.subject, diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 68fab7d38c..7c774527b3 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -1,6 +1,11 @@ """Constants for tests imap integration.""" -TEST_MESSAGE = ( + +DATE_HEADER1 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" +DATE_HEADER2 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100 (CET)\r\n" +DATE_HEADER_INVALID = b"2023-03-27T13:52:00 +0100\r\n" + +TEST_MESSAGE_HEADERS1 = ( b"Return-Path: \r\nDelivered-To: notify@example.com\r\n" b"Received: from beta.example.com\r\n\tby beta with LMTP\r\n\t" b"id eLp2M/GcHWQTLxQAho4UZQ\r\n\t(envelope-from )\r\n\t" @@ -8,13 +13,18 @@ TEST_MESSAGE = ( b"Received: from localhost (localhost [127.0.0.1])\r\n\t" b"by beta.example.com (Postfix) with ESMTP id D0FFA61425\r\n\t" b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" - b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" +) +TEST_MESSAGE_HEADERS2 = ( b"MIME-Version: 1.0\r\n" b"To: notify@example.com\r\n" b"From: John Doe \r\n" b"Subject: Test subject\r\n" ) +TEST_MESSAGE = TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 +TEST_MESSAGE_ALT = TEST_MESSAGE_HEADERS1 + DATE_HEADER2 + TEST_MESSAGE_HEADERS2 +TEST_INVALID_DATE = TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID + TEST_MESSAGE_HEADERS2 + TEST_CONTENT_TEXT_BARE = b"\r\n" b"Test body\r\n" b"\r\n" TEST_CONTENT_BINARY = ( @@ -88,6 +98,31 @@ TEST_FETCH_RESPONSE_TEXT_PLAIN = ( ], ) +TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE_ALT + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE_ALT + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_INVALID_DATE = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + + TEST_FETCH_RESPONSE_TEXT_OTHER = ( "OK", [ diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index ec9058830d..fdcf37b76b 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -1,6 +1,6 @@ """Test the imap entry initialization.""" import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -17,10 +17,12 @@ from .const import ( BAD_RESPONSE, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_INVALID_DATE, TEST_FETCH_RESPONSE_MULTIPART, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, TEST_SEARCH_RESPONSE, ) from .test_config_flow import MOCK_CONFIG @@ -66,20 +68,31 @@ async def test_entry_startup_fails( @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize( - "imap_fetch", + ("imap_fetch", "valid_date"), [ - TEST_FETCH_RESPONSE_TEXT_BARE, - TEST_FETCH_RESPONSE_TEXT_PLAIN, - TEST_FETCH_RESPONSE_TEXT_OTHER, - TEST_FETCH_RESPONSE_HTML, - TEST_FETCH_RESPONSE_MULTIPART, - TEST_FETCH_RESPONSE_BINARY, + (TEST_FETCH_RESPONSE_TEXT_BARE, True), + (TEST_FETCH_RESPONSE_TEXT_PLAIN, True), + (TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, True), + (TEST_FETCH_RESPONSE_INVALID_DATE, False), + (TEST_FETCH_RESPONSE_TEXT_OTHER, True), + (TEST_FETCH_RESPONSE_HTML, True), + (TEST_FETCH_RESPONSE_MULTIPART, True), + (TEST_FETCH_RESPONSE_BINARY, True), + ], + ids=[ + "bare", + "plain", + "plain_alt", + "invalid_date", + "other", + "html", + "multipart", + "binary", ], - ids=["bare", "plain", "other", "html", "multipart", "binary"], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_receiving_message_successfully( - hass: HomeAssistant, mock_imap_protocol: MagicMock + hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool ) -> None: """Test receiving a message successfully.""" event_called = async_capture_events(hass, "imap_content") @@ -106,6 +119,12 @@ async def test_receiving_message_successfully( assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" assert data["text"] + assert ( + valid_date + and isinstance(data["date"], datetime) + or not valid_date + and data["date"] is None + ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) From 0550b17d543c471851064e8c81cd7abbd2d0660b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 10:51:46 -1000 Subject: [PATCH 0281/1165] Rework recorder filters to avoid caching mistakes (#90419) --- homeassistant/components/recorder/filters.py | 89 +++++++++++-------- tests/components/history/test_init.py | 19 ++-- .../history/test_init_db_schema_30.py | 19 ++-- 3 files changed, 68 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index de0929cf9f..24d22704a8 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -63,42 +63,53 @@ def merge_include_exclude_filters( def sqlalchemy_filter_from_include_exclude_conf(conf: ConfigType) -> Filters | None: """Build a sql filter from config.""" - filters = Filters() - if exclude := conf.get(CONF_EXCLUDE): - filters.excluded_entities = exclude.get(CONF_ENTITIES, []) - filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - filters.excluded_entity_globs = exclude.get(CONF_ENTITY_GLOBS, []) - if include := conf.get(CONF_INCLUDE): - filters.included_entities = include.get(CONF_ENTITIES, []) - filters.included_domains = include.get(CONF_DOMAINS, []) - filters.included_entity_globs = include.get(CONF_ENTITY_GLOBS, []) - + exclude = conf.get(CONF_EXCLUDE, {}) + include = conf.get(CONF_INCLUDE, {}) + filters = Filters( + excluded_entities=exclude.get(CONF_ENTITIES, []), + excluded_domains=exclude.get(CONF_DOMAINS, []), + excluded_entity_globs=exclude.get(CONF_ENTITY_GLOBS, []), + included_entities=include.get(CONF_ENTITIES, []), + included_domains=include.get(CONF_DOMAINS, []), + included_entity_globs=include.get(CONF_ENTITY_GLOBS, []), + ) return filters if filters.has_config else None class Filters: - """Container for the configured include and exclude filters.""" + """Container for the configured include and exclude filters. - def __init__(self) -> None: + A filter must never change after it is created since it is used in a + cache key. + """ + + def __init__( + self, + excluded_entities: Collection[str] | None = None, + excluded_domains: Collection[str] | None = None, + excluded_entity_globs: Collection[str] | None = None, + included_entities: Collection[str] | None = None, + included_domains: Collection[str] | None = None, + included_entity_globs: Collection[str] | None = None, + ) -> None: """Initialise the include and exclude filters.""" - self.excluded_entities: Collection[str] = [] - self.excluded_domains: Collection[str] = [] - self.excluded_entity_globs: Collection[str] = [] - - self.included_entities: Collection[str] = [] - self.included_domains: Collection[str] = [] - self.included_entity_globs: Collection[str] = [] + self._excluded_entities = excluded_entities or [] + self._excluded_domains = excluded_domains or [] + self._excluded_entity_globs = excluded_entity_globs or [] + self._included_entities = included_entities or [] + self._included_domains = included_domains or [] + self._included_entity_globs = included_entity_globs or [] def __repr__(self) -> str: """Return human readable excludes/includes.""" return ( "" ) @@ -110,17 +121,17 @@ class Filters: @property def _have_exclude(self) -> bool: return bool( - self.excluded_entities - or self.excluded_domains - or self.excluded_entity_globs + self._excluded_entities + or self._excluded_domains + or self._excluded_entity_globs ) @property def _have_include(self) -> bool: return bool( - self.included_entities - or self.included_domains - or self.included_entity_globs + self._included_entities + or self._included_domains + or self._included_entity_globs ) def _generate_filter_for_columns( @@ -130,14 +141,14 @@ class Filters: This must match exactly how homeassistant.helpers.entityfilter works. """ - i_domains = _domain_matcher(self.included_domains, columns, encoder) - i_entities = _entity_matcher(self.included_entities, columns, encoder) - i_entity_globs = _globs_to_like(self.included_entity_globs, columns, encoder) + i_domains = _domain_matcher(self._included_domains, columns, encoder) + i_entities = _entity_matcher(self._included_entities, columns, encoder) + i_entity_globs = _globs_to_like(self._included_entity_globs, columns, encoder) includes = [i_domains, i_entities, i_entity_globs] - e_domains = _domain_matcher(self.excluded_domains, columns, encoder) - e_entities = _entity_matcher(self.excluded_entities, columns, encoder) - e_entity_globs = _globs_to_like(self.excluded_entity_globs, columns, encoder) + e_domains = _domain_matcher(self._excluded_domains, columns, encoder) + e_entities = _entity_matcher(self._excluded_entities, columns, encoder) + e_entity_globs = _globs_to_like(self._excluded_entity_globs, columns, encoder) excludes = [e_domains, e_entities, e_entity_globs] have_exclude = self._have_exclude @@ -173,7 +184,7 @@ class Filters: # - Otherwise, entity matches glob exclude: exclude # - Otherwise, entity matches domain include: include # - Otherwise: exclude - if self.included_domains or self.included_entity_globs: + if self._included_domains or self._included_entity_globs: return or_( i_entities, # https://github.com/sqlalchemy/sqlalchemy/issues/9190 @@ -187,7 +198,7 @@ class Filters: # - Otherwise, entity matches glob exclude: exclude # - Otherwise, entity matches domain exclude: exclude # - Otherwise: include - if self.excluded_domains or self.excluded_entity_globs: + if self._excluded_domains or self._excluded_entity_globs: return (not_(or_(*excludes)) | i_entities).self_group() # type: ignore[no-any-return, no-untyped-call] # Case 6 - No Domain and/or glob includes or excludes diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index a5c3919505..8b46bd9760 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -545,16 +545,15 @@ def test_get_significant_states_only(hass_history) -> None: def check_significant_states(hass, zero, four, states, config): """Check if significant states are retrieved.""" - filters = history.Filters() - exclude = config[history.DOMAIN].get(CONF_EXCLUDE) - if exclude: - filters.excluded_entities = exclude.get(CONF_ENTITIES, []) - filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - include = config[history.DOMAIN].get(CONF_INCLUDE) - if include: - filters.included_entities = include.get(CONF_ENTITIES, []) - filters.included_domains = include.get(CONF_DOMAINS, []) - + domain_config = config[history.DOMAIN] + exclude = domain_config.get(CONF_EXCLUDE, {}) + include = domain_config.get(CONF_INCLUDE, {}) + filters = history.Filters( + excluded_entities=exclude.get(CONF_ENTITIES, []), + excluded_domains=exclude.get(CONF_DOMAINS, []), + included_entities=include.get(CONF_ENTITIES, []), + included_domains=include.get(CONF_DOMAINS, []), + ) hist = get_significant_states(hass, zero, four, filters=filters) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index a300f58b96..7668d6794d 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -600,16 +600,15 @@ def test_get_significant_states_only(legacy_hass_history) -> None: def check_significant_states(hass, zero, four, states, config): """Check if significant states are retrieved.""" - filters = history.Filters() - exclude = config[history.DOMAIN].get(CONF_EXCLUDE) - if exclude: - filters.excluded_entities = exclude.get(CONF_ENTITIES, []) - filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - include = config[history.DOMAIN].get(CONF_INCLUDE) - if include: - filters.included_entities = include.get(CONF_ENTITIES, []) - filters.included_domains = include.get(CONF_DOMAINS, []) - + domain_config = config[history.DOMAIN] + exclude = domain_config.get(CONF_EXCLUDE, {}) + include = domain_config.get(CONF_INCLUDE, {}) + filters = history.Filters( + excluded_entities=exclude.get(CONF_ENTITIES, []), + excluded_domains=exclude.get(CONF_DOMAINS, []), + included_entities=include.get(CONF_ENTITIES, []), + included_domains=include.get(CONF_DOMAINS, []), + ) hist = get_significant_states(hass, zero, four, filters=filters) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) From e22618a555f9d28522b355211a2f53a2bf436b43 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 22:56:51 +0200 Subject: [PATCH 0282/1165] Write protect entity options (#90185) --- homeassistant/components/camera/prefs.py | 3 +- homeassistant/components/sensor/__init__.py | 2 +- homeassistant/helpers/entity_registry.py | 33 +++++++++++++++++---- tests/helpers/test_entity_registry.py | 10 +++++++ tests/syrupy.py | 1 + 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 28e4e1eeac..160f896c86 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -1,6 +1,7 @@ """Preference management for camera component.""" from __future__ import annotations +from collections.abc import Mapping from dataclasses import asdict, dataclass from typing import Final, cast @@ -89,7 +90,7 @@ class CameraPreferences: # Get preload stream setting from prefs # Get orientation setting from entity registry reg_entry = er.async_get(self._hass).async_get(entity_id) - er_prefs = reg_entry.options.get(DOMAIN, {}) if reg_entry else {} + er_prefs: Mapping = reg_entry.options.get(DOMAIN, {}) if reg_entry else {} preload_prefs = await self._store.async_load() or {} settings = DynamicStreamSettings( preload_stream=cast( diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 1812f41693..4f56be77a9 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -737,7 +737,7 @@ class SensorEntity(Entity): or "suggested_display_precision" not in self.registry_entry.options ): return - sensor_options = self.registry_entry.options.get(DOMAIN, {}) + sensor_options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) if ( "suggested_display_precision" in sensor_options and sensor_options["suggested_display_precision"] == display_precision diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 4c192d916c..9cb119b81b 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -12,6 +12,7 @@ from __future__ import annotations from collections import UserDict from collections.abc import Callable, Iterable, Mapping, ValuesView import logging +from types import MappingProxyType from typing import TYPE_CHECKING, Any, TypeVar, cast import attr @@ -111,6 +112,29 @@ DISLAY_DICT_OPTIONAL = ( ) +class _EntityOptions(UserDict[str, MappingProxyType]): + """Container for entity options.""" + + def __init__(self, data: Mapping[str, Mapping] | None) -> None: + """Initialize.""" + super().__init__() + if data is None: + return + self.data = {key: MappingProxyType(val) for key, val in data.items()} + + def __setitem__(self, key: str, entry: Mapping) -> None: + """Add an item.""" + raise NotImplementedError + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + raise NotImplementedError + + def as_dict(self) -> dict[str, dict]: + """Return dictionary version.""" + return {key: dict(val) for key, val in self.data.items()} + + @attr.s(slots=True, frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -132,10 +156,7 @@ class RegistryEntry: id: str = attr.ib(factory=uuid_util.random_uuid_hex) has_entity_name: bool = attr.ib(default=False) name: str | None = attr.ib(default=None) - options: EntityOptionsType = attr.ib( - default=None, - converter=attr.converters.default_if_none(factory=dict), # type: ignore[misc] - ) + options: _EntityOptions = attr.ib(default=None, converter=_EntityOptions) # As set by integration original_device_class: str | None = attr.ib(default=None) original_icon: str | None = attr.ib(default=None) @@ -930,7 +951,7 @@ class EntityRegistry: If the domain options are set to None, they will be removed. """ old = self.entities[entity_id] - new_options = { + new_options: dict[str, Mapping] = { key: value for key, value in old.options.items() if key != domain } if options is not None: @@ -1010,7 +1031,7 @@ class EntityRegistry: "id": entry.id, "has_entity_name": entry.has_entity_name, "name": entry.name, - "options": entry.options, + "options": entry.options.as_dict(), "original_device_class": entry.original_device_class, "original_icon": entry.original_icon, "original_name": entry.original_name, diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 79d6de3290..e3b91c46e1 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -747,6 +747,16 @@ async def test_update_entity_options(entity_registry: er.EntityRegistry) -> None assert entry.options == {} assert new_entry_1.options == {"light": {"minimum_brightness": 20}} + # Test it's not possible to modify the options + with pytest.raises(NotImplementedError): + new_entry_1.options["blah"] = {} + with pytest.raises(NotImplementedError): + new_entry_1.options["light"] = {} + with pytest.raises(TypeError): + new_entry_1.options["light"]["blah"] = 123 + with pytest.raises(TypeError): + new_entry_1.options["light"]["minimum_brightness"] = 123 + entity_registry.async_update_entity_options( entry.entity_id, "light", {"minimum_brightness": 30} ) diff --git a/tests/syrupy.py b/tests/syrupy.py index f18c11bf5d..af34cb628f 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -170,6 +170,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): "config_entry_id": ANY, "device_id": ANY, "id": ANY, + "options": data.options.as_dict(), } ) serialized.pop("_partial_repr") From f60e9c71a2135d2b18f433a194a733acdb7f1908 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 11:22:41 -1000 Subject: [PATCH 0283/1165] Make bootstrap cancelation safe (#90420) --- homeassistant/bootstrap.py | 19 +++++++++---------- tests/test_bootstrap.py | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 9ba4e99a08..eb3aa3a223 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -515,16 +515,15 @@ async def async_setup_multi_components( ) for domain in domains } - await asyncio.wait(futures.values()) - errors = [domain for domain in domains if futures[domain].exception()] - for domain in errors: - exception = futures[domain].exception() - assert exception is not None - _LOGGER.error( - "Error setting up integration %s - received exception", - domain, - exc_info=(type(exception), exception, exception.__traceback__), - ) + results = await asyncio.gather(*futures.values(), return_exceptions=True) + for idx, domain in enumerate(futures): + result = results[idx] + if isinstance(result, BaseException): + _LOGGER.error( + "Error setting up integration %s - received exception", + domain, + exc_info=(type(result), result, result.__traceback__), + ) async def _async_set_up_integrations( diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 9f02d6394e..cd0d7ef069 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -806,3 +806,26 @@ async def test_warning_logged_on_wrap_up_timeout( await hass.async_block_till_done() assert "Setup timed out for bootstrap - moving forward" in caplog.text + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_is_cancellation_safe( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test cancellation during async_setup_component does not cancel bootstrap.""" + with patch.object( + bootstrap, "async_setup_component", side_effect=asyncio.CancelledError + ): + await bootstrap._async_set_up_integrations(hass, {"cancel_integration": {}}) + await hass.async_block_till_done() + + assert "Error setting up integration cancel_integration" in caplog.text + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_empty_integrations( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setting up an empty integrations does not raise.""" + await bootstrap.async_setup_multi_components(hass, set(), {}) + await hass.async_block_till_done() From 9ae02362083077a7122e821edc4c0f96dfc05532 Mon Sep 17 00:00:00 2001 From: mletenay Date: Tue, 28 Mar 2023 23:31:14 +0200 Subject: [PATCH 0284/1165] Add goodwe sensors for apparent/reactive pwr (#87940) --- homeassistant/components/goodwe/sensor.py | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index b4adf97c3e..d76d620283 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -19,13 +19,16 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, EntityCategory, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, UnitOfPower, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo @@ -111,6 +114,20 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { value=lambda coordinator, sensor: coordinator.total_sensor_value(sensor), available=lambda coordinator: coordinator.data is not None, ), + "VA": GoodweSensorEntityDescription( + key="VA", + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + entity_registry_enabled_default=False, + ), + "var": GoodweSensorEntityDescription( + key="var", + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + entity_registry_enabled_default=False, + ), "C": GoodweSensorEntityDescription( key="C", device_class=SensorDeviceClass.TEMPERATURE, @@ -123,6 +140,13 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, ), + "h": GoodweSensorEntityDescription( + key="h", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_registry_enabled_default=False, + ), "%": GoodweSensorEntityDescription( key="%", state_class=SensorStateClass.MEASUREMENT, From 9dc936f8b9a443a9052210c0b6d6e89369193f48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 12:02:35 -1000 Subject: [PATCH 0285/1165] Add sqlalchemy LRUs to the profiler lru service (#90428) --- homeassistant/components/profiler/__init__.py | 12 +++++++++++- tests/components/profiler/test_init.py | 10 ++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index b838f67d02..27e302f47c 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -35,6 +35,7 @@ SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" _LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ +_SQLALCHEMY_LRU_OBJECT = "LRUCache" _KNOWN_LRU_CLASSES = ( "EventDataManager", @@ -67,7 +68,9 @@ LOG_INTERVAL_SUB = "log_interval_subscription" _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Set up Profiler from a config entry.""" lock = asyncio.Lock() domain_data = hass.data[DOMAIN] = {} @@ -176,6 +179,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: maybe_lru.get_stats(), ) + for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT): + if (data := getattr(lru, "_data", None)) and isinstance(data, dict): + for key, value in dict(data).items(): + _LOGGER.critical( + "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value + ) + persistent_notification.create( hass, ( diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index af642c779e..9466660dca 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -11,6 +11,7 @@ import pytest from homeassistant.components.profiler import ( _LRU_CACHE_WRAPPER_OBJECT, + _SQLALCHEMY_LRU_OBJECT, CONF_SECONDS, SERVICE_DUMP_LOG_OBJECTS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, @@ -254,9 +255,17 @@ async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) domain_data = DomainData() assert hass.services.has_service(DOMAIN, SERVICE_LRU_STATS) + class LRUCache: + def __init__(self): + self._data = {"sqlalchemy_test": 1} + + sqlalchemy_lru_cache = LRUCache() + def _mock_by_type(type_): if type_ == _LRU_CACHE_WRAPPER_OBJECT: return [_dummy_test_lru_stats] + if type_ == _SQLALCHEMY_LRU_OBJECT: + return [sqlalchemy_lru_cache] return [domain_data] with patch("objgraph.by_type", side_effect=_mock_by_type): @@ -266,3 +275,4 @@ async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) assert "(0, 0)" in caplog.text assert "_dummy_test_lru_stats" in caplog.text assert "CacheInfo" in caplog.text + assert "sqlalchemy_test" in caplog.text From ee2101ef38870149ce1b61e46596259c847fdd35 Mon Sep 17 00:00:00 2001 From: MattWestb <49618193+MattWestb@users.noreply.github.com> Date: Wed, 29 Mar 2023 01:33:06 +0200 Subject: [PATCH 0286/1165] Add binding of IKEA Matter Switch cluster in ZHA (#89623) * Adding binding of IKEA Matter Switch cluster IKEA Symfonisk Gen 2 is using Matter ZCL Switch command but on manufacture cluster then its not supported in ZVL R8 that need being bond for sending the commands to the coordinator. * Update manufacturerspecific.py * Update manufacturerspecific.py Delete not needed function `@registries.BINDABLE_CLUSTERS.register(0xFC80)` --- .../components/zha/core/channels/manufacturerspecific.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 85a478b0d4..107df3a2da 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -324,3 +324,11 @@ class IkeaAirPurifierChannel(ZigbeeChannel): self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value ) + + +@registries.CHANNEL_ONLY_CLUSTERS.register(0xFC80) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFC80) +class IkeaRemote(ZigbeeChannel): + """Ikea Matter remote channel.""" + + REPORT_CONFIG = () From 4f05246654625787f153b23166e315970329802c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 13:54:33 -1000 Subject: [PATCH 0287/1165] Bump onvif-zeep-async to 1.2.3 (#90382) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 4b998bdd6c..ef4497fa28 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.2.2", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==1.2.3", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c5dec6d8c..ca51ee1b7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1260,7 +1260,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.2.2 +onvif-zeep-async==1.2.3 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ab64c066c..6bfaf522c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.2.2 +onvif-zeep-async==1.2.3 # homeassistant.components.opengarage open-garage==0.2.0 From 86600350275c695bb5c867ea09fde6f13d2abec0 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 28 Mar 2023 20:59:26 -0400 Subject: [PATCH 0288/1165] Bump ZHA quirks to 0.0.95 (#90435) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 3061d867b6..d82fe5ed0f 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "bellows==0.34.10", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.94", + "zha-quirks==0.0.95", "zigpy-deconz==0.19.2", "zigpy==0.53.2", "zigpy-xbee==0.16.2", diff --git a/requirements_all.txt b/requirements_all.txt index ca51ee1b7f..49ac9171ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2701,7 +2701,7 @@ zeroconf==0.47.4 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.94 +zha-quirks==0.0.95 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6bfaf522c6..4213338da8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1935,7 +1935,7 @@ zeroconf==0.47.4 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.94 +zha-quirks==0.0.95 # homeassistant.components.zha zigpy-deconz==0.19.2 From 12f49006cfc699120f874d0b97e9357ba9302e64 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Mar 2023 03:12:21 +0200 Subject: [PATCH 0289/1165] Add Aqara E1 thermostat entities to ZHA (#90158) * Add Aqara E1 thermostat entities (WIP) * Remove calibrate button for now * Add diagnostic entity category to calibrated + external sensor * Add multiplier for ZHA config number/away preset temp * Set default multiplier correctly * Add and use `CONFIG_DIAGNOSTIC_MATCH` for diagnostic entities --- homeassistant/components/zha/binary_sensor.py | 41 ++++++++++++++++++- .../zha/core/channels/manufacturerspecific.py | 14 +++++++ homeassistant/components/zha/number.py | 24 +++++++++-- homeassistant/components/zha/select.py | 17 ++++++++ homeassistant/components/zha/switch.py | 29 +++++++++++++ 5 files changed, 121 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 9c2fb49de6..fc49026d88 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,6 +39,9 @@ CLASS_MAPPING = { STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.BINARY_SENSOR) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BINARY_SENSOR) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.BINARY_SENSOR +) async def async_setup_entry( @@ -201,3 +204,39 @@ class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): SENSOR_ATTR = "consumer_connected" _attr_name: str = "Consumer connected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatWindowOpen(BinarySensor, id_suffix="window_open"): + """ZHA Aqara thermostat window open binary sensor.""" + + SENSOR_ATTR = "window_open" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.WINDOW + _attr_name: str = "Window open" + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatValveAlarm(BinarySensor, id_suffix="valve_alarm"): + """ZHA Aqara thermostat valve alarm binary sensor.""" + + SENSOR_ATTR = "valve_alarm" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM + _attr_name: str = "Valve alarm" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatCalibrated(BinarySensor, id_suffix="calibrated"): + """ZHA Aqara thermostat calibrated binary sensor.""" + + SENSOR_ATTR = "calibrated" + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + _attr_name: str = "Calibrated" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatExternalSensor(BinarySensor, id_suffix="sensor"): + """ZHA Aqara thermostat external sensor binary sensor.""" + + SENSOR_ATTR = "sensor" + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + _attr_name: str = "External sensor" diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 107df3a2da..629e618db0 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -138,6 +138,20 @@ class OppleRemote(ZigbeeChannel): "serving_size": True, "portion_weight": True, } + elif self.cluster.endpoint.model == "lumi.airrtc.agl001": + self.ZCL_INIT_ATTRS = { + "system_mode": True, + "preset": True, + "window_detection": True, + "valve_detection": True, + "valve_alarm": True, + "child_lock": True, + "away_preset_temperature": True, + "window_open": True, + "calibrated": True, + "schedule": True, + "sensor": True, + } async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Initialize channel specific.""" diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 334b72dccc..d0ec62eaf6 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -11,7 +11,7 @@ from zigpy.zcl.foundation import Status from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform, UnitOfMass +from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -375,6 +375,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): _attr_entity_category = EntityCategory.CONFIG _attr_native_step: float = 1.0 + _attr_multiplier: float = 1 _zcl_attribute: str @classmethod @@ -417,13 +418,13 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): @property def native_value(self) -> float: """Return the current value.""" - return self._channel.cluster.get(self._zcl_attribute) + return self._channel.cluster.get(self._zcl_attribute) * self._attr_multiplier async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" try: res = await self._channel.cluster.write_attributes( - {self._zcl_attribute: int(value)} + {self._zcl_attribute: int(value / self._attr_multiplier)} ) except zigpy.exceptions.ZigbeeException as ex: self.error("Could not set value: %s", ex) @@ -861,3 +862,20 @@ class AqaraPetFeederPortionWeight( _attr_mode: NumberMode = NumberMode.BOX _attr_native_unit_of_measurement: str = UnitOfMass.GRAMS _attr_icon: str = "mdi:weight-gram" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatAwayTemp( + ZHANumberConfigurationEntity, id_suffix="away_preset_temperature" +): + """Aqara away preset temperature configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: float = 5 + _attr_native_max_value: float = 30 + _attr_multiplier: float = 0.01 + _zcl_attribute: str = "away_preset_temperature" + _attr_name: str = "Away preset temperature" + _attr_mode: NumberMode = NumberMode.SLIDER + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_icon: str = ICONS[0] diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index b4cbce5540..605c7d507c 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -503,3 +503,20 @@ class AqaraPetFeederMode(ZCLEnumSelectEntity, id_suffix="feeding_mode"): _enum = AqaraFeedingMode _attr_name = "Mode" _attr_icon: str = "mdi:wrench-clock" + + +class AqaraThermostatPresetMode(types.enum8): + """Thermostat preset mode.""" + + Manual = 0x00 + Auto = 0x01 + Away = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatPreset(ZCLEnumSelectEntity, id_suffix="preset"): + """Representation of an Aqara thermostat preset configuration entity.""" + + _select_attr = "preset" + _enum = AqaraThermostatPresetMode + _attr_name = "Preset" diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 09cebc8f4c..9323e3ddc8 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -477,3 +477,32 @@ class TuyaChildLockSwitch(ZHASwitchConfigurationEntity, id_suffix="child_lock"): _zcl_attribute: str = "child_lock" _attr_name = "Child lock" _attr_icon: str = "mdi:account-lock" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatWindowDetection( + ZHASwitchConfigurationEntity, id_suffix="window_detection" +): + """Representation of an Aqara thermostat window detection configuration entity.""" + + _zcl_attribute: str = "window_detection" + _attr_name = "Window detection" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatValveDetection( + ZHASwitchConfigurationEntity, id_suffix="valve_detection" +): + """Representation of an Aqara thermostat valve detection configuration entity.""" + + _zcl_attribute: str = "valve_detection" + _attr_name = "Valve detection" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): + """Representation of an Aqara thermostat child lock configuration entity.""" + + _zcl_attribute: str = "child_lock" + _attr_name = "Child lock" + _attr_icon: str = "mdi:account-lock" From 47a2598b6652b46e31b1a9a55f7614a83ed49b24 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Mar 2023 03:30:56 +0200 Subject: [PATCH 0290/1165] Add Aqara smoke sensor entities to ZHA (#90159) * Add Aqara smoke sensor entities (WIP) * Update smoke sensor entities (WIP) * Drop two init attributes * Move self-test button * Remove self-test switch, add icons * Add smoke sensor dbm entity * Also add SMOKE device class to linkage alarm Note: Enable "Linkage alarm" for this --- homeassistant/components/zha/binary_sensor.py | 9 ++++ homeassistant/components/zha/button.py | 12 +++++ .../zha/core/channels/manufacturerspecific.py | 9 ++++ homeassistant/components/zha/sensor.py | 12 +++++ homeassistant/components/zha/switch.py | 50 +++++++++++++++++++ 5 files changed, 92 insertions(+) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index fc49026d88..b277b3fe26 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -240,3 +240,12 @@ class AqaraThermostatExternalSensor(BinarySensor, id_suffix="sensor"): SENSOR_ATTR = "sensor" _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC _attr_name: str = "External sensor" + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +class AqaraLinkageAlarmState(BinarySensor, id_suffix="linkage_alarm_state"): + """ZHA Aqara linkage alarm state binary sensor.""" + + SENSOR_ATTR = "linkage_alarm_state" + _attr_name: str = "Linkage alarm state" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.SMOKE diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 14547216dc..b3ff3f5aed 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -184,3 +184,15 @@ class AqaraPetFeederFeedButton(ZHAAttributeButton, id_suffix="feeding"): _attribute_name = "feeding" _attr_name = "Feed" _attribute_value = 1 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraSelfTestButton(ZHAAttributeButton, id_suffix="self_test"): + """Defines a ZHA self-test button for Aqara smoke sensors.""" + + _attribute_name = "self_test" + _attr_name = "Self-test" + _attribute_value = 1 + _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 629e618db0..e312f398b5 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -152,6 +152,15 @@ class OppleRemote(ZigbeeChannel): "schedule": True, "sensor": True, } + elif self.cluster.endpoint.model == "lumi.sensor_smoke.acn03": + self.ZCL_INIT_ATTRS = { + "buzzer_manual_mute": True, + "smoke_density": True, + "heartbeat_indicator": True, + "buzzer_manual_alarm": True, + "buzzer": True, + "linkage_alarm": True, + } async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Initialize channel specific.""" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 78ce47c7e5..a7a090b13a 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -955,3 +955,15 @@ class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): _attr_native_unit_of_measurement = UnitOfMass.GRAMS _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_icon: str = "mdi:weight-gram" + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +class AqaraSmokeDensityDbm(Sensor, id_suffix="smoke_density_dbm"): + """Sensor that displays the smoke density of an Aqara smoke sensor in dB/m.""" + + SENSOR_ATTR = "smoke_density_dbm" + _attr_name: str = "Smoke density" + _attr_native_unit_of_measurement = "dB/m" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_icon: str = "mdi:google-circles-communities" + _attr_suggested_display_precision: int = 3 diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 9323e3ddc8..f0e3675079 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -506,3 +506,53 @@ class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lo _zcl_attribute: str = "child_lock" _attr_name = "Child lock" _attr_icon: str = "mdi:account-lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraHeartbeatIndicator( + ZHASwitchConfigurationEntity, id_suffix="heartbeat_indicator" +): + """Representation of a heartbeat indicator configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "heartbeat_indicator" + _attr_name = "Heartbeat indicator" + _attr_icon: str = "mdi:heart-flash" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraLinkageAlarm(ZHASwitchConfigurationEntity, id_suffix="linkage_alarm"): + """Representation of a linkage alarm configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "linkage_alarm" + _attr_name = "Linkage alarm" + _attr_icon: str = "mdi:shield-link-variant" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraBuzzerManualMute( + ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_mute" +): + """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "buzzer_manual_mute" + _attr_name = "Buzzer manual mute" + _attr_icon: str = "mdi:volume-off" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraBuzzerManualAlarm( + ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_alarm" +): + """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "buzzer_manual_alarm" + _attr_name = "Buzzer manual alarm" + _attr_icon: str = "mdi:bullhorn" From e3cad8baac96ec60cfc1d30fbf6d9bdd03ffe12b Mon Sep 17 00:00:00 2001 From: Thijs W Date: Wed, 29 Mar 2023 04:06:21 +0200 Subject: [PATCH 0291/1165] Migrate ssdp to config_flow for frontier_silicon (#89496) Co-authored-by: J. Nick Koston --- .../components/discovery/__init__.py | 1 - .../frontier_silicon/config_flow.py | 97 ++++++++++-- .../components/frontier_silicon/const.py | 3 + .../components/frontier_silicon/manifest.json | 3 +- .../frontier_silicon/media_player.py | 14 -- homeassistant/generated/ssdp.py | 5 + .../frontier_silicon/test_config_flow.py | 143 ++++++++++++++++-- 7 files changed, 221 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 0ffd6fe49e..204992b48f 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -60,7 +60,6 @@ class ServiceDetails(NamedTuple): SERVICE_HANDLERS = { SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), "yamaha": ServiceDetails("media_player", "yamaha"), - "frontier_silicon": ServiceDetails("media_player", "frontier_silicon"), "openhome": ServiceDetails("media_player", "openhome"), "bluesound": ServiceDetails("media_player", "bluesound"), } diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index a3fbdb52c1..a054bd2b30 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -3,15 +3,24 @@ from __future__ import annotations import logging from typing import Any +from urllib.parse import urlparse from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import ssdp from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult -from .const import CONF_PIN, CONF_WEBFSAPI_URL, DEFAULT_PIN, DEFAULT_PORT, DOMAIN +from .const import ( + CONF_PIN, + CONF_WEBFSAPI_URL, + DEFAULT_PIN, + DEFAULT_PORT, + DOMAIN, + SSDP_ATTR_SPEAKER_NAME, +) _LOGGER = logging.getLogger(__name__) @@ -32,11 +41,17 @@ STEP_DEVICE_CONFIG_DATA_SCHEMA = vol.Schema( ) +def hostname_from_url(url: str) -> str: + """Return the hostname from a url.""" + return str(urlparse(url).hostname) + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Frontier Silicon Media Player.""" VERSION = 1 + _name: str _webfsapi_url: str async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: @@ -101,6 +116,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: + """Process entity discovered via SSDP.""" + + device_url = discovery_info.ssdp_location + if device_url is None: + return self.async_abort(reason="cannot_connect") + + device_hostname = hostname_from_url(device_url) + for entry in self._async_current_entries(include_ignore=False): + if device_hostname == hostname_from_url(entry.data[CONF_WEBFSAPI_URL]): + return self.async_abort(reason="already_configured") + + speaker_name = discovery_info.ssdp_headers.get(SSDP_ATTR_SPEAKER_NAME) + self.context["title_placeholders"] = {"name": speaker_name} + + try: + self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) + except FSConnectionError: + return self.async_abort(reason="cannot_connect") + except Exception as exception: # pylint: disable=broad-except + _LOGGER.debug(exception) + return self.async_abort(reason="unknown") + + try: + # try to login with default pin + afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) + + unique_id = await afsapi.get_radio_id() + except InvalidPinException: + return self.async_abort(reason="invalid_auth") + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_WEBFSAPI_URL: self._webfsapi_url}, reload_on_update=True + ) + + self._name = await afsapi.get_friendly_name() + + return await self.async_step_confirm() + async def _async_step_device_config_if_needed(self) -> FlowResult: """Most users will not have changed the default PIN on their radio. @@ -111,21 +166,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # try to login with default pin afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) - name = await afsapi.get_friendly_name() + self._name = await afsapi.get_friendly_name() except InvalidPinException: # Ask for a PIN return await self.async_step_device_config() - self.context["title_placeholders"] = {"name": name} + self.context["title_placeholders"] = {"name": self._name} unique_id = await afsapi.get_radio_id() await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=name, - data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: DEFAULT_PIN}, - ) + return await self._async_create_entry() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Allow the user to confirm adding the device. Used when the default PIN could successfully be used.""" + + if user_input is not None: + return await self._async_create_entry() + + self._set_confirm_only() + return self.async_show_form(step_id="confirm") async def async_step_device_config( self, user_input: dict[str, Any] | None = None @@ -145,7 +208,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN]) - name = await afsapi.get_friendly_name() + self._name = await afsapi.get_friendly_name() except FSConnectionError: errors["base"] = "cannot_connect" @@ -156,15 +219,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: unique_id = await afsapi.get_radio_id() - await self.async_set_unique_id(unique_id) + await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=name, - data={ - CONF_WEBFSAPI_URL: self._webfsapi_url, - CONF_PIN: user_input[CONF_PIN], - }, - ) + return await self._async_create_entry(user_input[CONF_PIN]) data_schema = self.add_suggested_values_to_schema( STEP_DEVICE_CONFIG_DATA_SCHEMA, user_input @@ -174,3 +231,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, errors=errors, ) + + async def _async_create_entry(self, pin: str | None = None): + """Create the entry.""" + + return self.async_create_entry( + title=self._name, + data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: pin or DEFAULT_PIN}, + ) diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py index 9206db8916..34201fe8f4 100644 --- a/homeassistant/components/frontier_silicon/const.py +++ b/homeassistant/components/frontier_silicon/const.py @@ -4,6 +4,9 @@ DOMAIN = "frontier_silicon" CONF_WEBFSAPI_URL = "webfsapi_url" CONF_PIN = "pin" +SSDP_ST = "urn:schemas-frontier-silicon-com:undok:fsapi:1" +SSDP_ATTR_SPEAKER_NAME = "SPEAKER-NAME" + DEFAULT_PIN = "1234" DEFAULT_PORT = 80 diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 62e7e61703..9cc928e6f8 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", "iot_class": "local_polling", - "requirements": ["afsapi==0.2.7"] + "requirements": ["afsapi==0.2.7"], + "ssdp": [{ "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" }] } diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 7f73823239..54c17429b5 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -54,21 +54,7 @@ async def async_setup_platform( """Set up the Frontier Silicon platform. YAML is deprecated, and imported automatically. - SSDP discovery is temporarily retained - to be refactor subsequently. """ - if discovery_info is not None: - webfsapi_url = await AFSAPI.get_webfsapi_endpoint( - discovery_info["ssdp_description"] - ) - afsapi = AFSAPI(webfsapi_url, DEFAULT_PIN) - - name = await afsapi.get_friendly_name() - async_add_entities( - [AFSAPIDevice(name, afsapi)], - True, - ) - - return ir.async_create_issue( hass, diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index e5e83d5eae..3f26ec8fa7 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -130,6 +130,11 @@ SSDP = { "st": "urn:schemas-upnp-org:device:fritzbox:1", }, ], + "frontier_silicon": [ + { + "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1", + }, + ], "harmony": [ { "deviceType": "urn:myharmony-com:device:harmony:1", diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index 6a61f0b618..612058af0a 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -5,7 +5,12 @@ from afsapi import ConnectionError, InvalidPinException import pytest from homeassistant import config_entries -from homeassistant.components.frontier_silicon.const import CONF_WEBFSAPI_URL, DOMAIN +from homeassistant.components import ssdp +from homeassistant.components.frontier_silicon.const import ( + CONF_WEBFSAPI_URL, + DEFAULT_PIN, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -15,6 +20,23 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") +MOCK_DISCOVERY = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", + ssdp_st="mock_st", + ssdp_location="http://1.1.1.1/device", + upnp={"SPEAKER-NAME": "Speaker Name"}, +) + +INVALID_MOCK_DISCOVERY = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", + ssdp_st="mock_st", + ssdp_location=None, + upnp={"SPEAKER-NAME": "Speaker Name"}, +) + + async def test_import_success(hass: HomeAssistant) -> None: """Test successful import.""" @@ -49,7 +71,7 @@ async def test_import_webfsapi_endpoint_failures( ) -> None: """Test various failure of get_webfsapi_endpoint.""" with patch( - "afsapi.AFSAPI.get_webfsapi_endpoint", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint", side_effect=webfsapi_endpoint_error, ): result = await hass.config_entries.flow.async_init( @@ -80,7 +102,7 @@ async def test_import_radio_id_failures( ) -> None: """Test various failure of get_radio_id.""" with patch( - "afsapi.AFSAPI.get_radio_id", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", side_effect=radio_id_error, ): result = await hass.config_entries.flow.async_init( @@ -157,7 +179,7 @@ async def test_form_nondefault_pin( assert result["errors"] == {} with patch( - "afsapi.AFSAPI.get_friendly_name", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", side_effect=InvalidPinException, ): result2 = await hass.config_entries.flow.async_configure( @@ -179,8 +201,8 @@ async def test_form_nondefault_pin( assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "Name of the device" assert result3["data"] == { - "webfsapi_url": "http://1.1.1.1:80/webfsapi", - "pin": "4321", + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "4321", } mock_setup_entry.assert_called_once() @@ -208,7 +230,7 @@ async def test_form_nondefault_pin_invalid( assert result["errors"] == {} with patch( - "afsapi.AFSAPI.get_friendly_name", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", side_effect=InvalidPinException, ): result2 = await hass.config_entries.flow.async_configure( @@ -222,7 +244,7 @@ async def test_form_nondefault_pin_invalid( assert result2["errors"] is None with patch( - "afsapi.AFSAPI.get_friendly_name", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", side_effect=friendly_name_error, ): result3 = await hass.config_entries.flow.async_configure( @@ -244,8 +266,8 @@ async def test_form_nondefault_pin_invalid( assert result4["type"] == FlowResultType.CREATE_ENTRY assert result4["title"] == "Name of the device" assert result4["data"] == { - "webfsapi_url": "http://1.1.1.1:80/webfsapi", - "pin": "4321", + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "4321", } mock_setup_entry.assert_called_once() @@ -272,7 +294,7 @@ async def test_invalid_device_url( assert result["errors"] == {} with patch( - "afsapi.AFSAPI.get_webfsapi_endpoint", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint", side_effect=webfsapi_endpoint_error, ): result2 = await hass.config_entries.flow.async_configure( @@ -294,7 +316,102 @@ async def test_invalid_device_url( assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "Name of the device" assert result3["data"] == { - "webfsapi_url": "http://1.1.1.1:80/webfsapi", - "pin": "1234", + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "1234", } mock_setup_entry.assert_called_once() + + +async def test_ssdp(hass: HomeAssistant, mock_setup_entry: MockConfigEntry) -> None: + """Test a device being discovered.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Name of the device" + assert result2["data"] == { + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: DEFAULT_PIN, + } + mock_setup_entry.assert_called_once() + + +async def test_ssdp_invalid_location(hass: HomeAssistant) -> None: + """Test a device being discovered.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=INVALID_MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_ssdp_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test an already known device being discovered.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("webfsapi_endpoint_error", "result_error"), + [(ValueError, "unknown"), (ConnectionError, "cannot_connect")], +) +async def test_ssdp_fail( + hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_error: str +) -> None: + """Test a device being discovered but failing to reply.""" + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint", + side_effect=webfsapi_endpoint_error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == result_error + + +async def test_ssdp_nondefault_pin(hass: HomeAssistant) -> None: + """Test a device being discovered.""" + + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + side_effect=InvalidPinException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "invalid_auth" From 2c7c8ccbfe55a70fd5f6e5722c2f3902b1d9fe7e Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 28 Mar 2023 20:36:26 -0600 Subject: [PATCH 0292/1165] Fix bluetooth polling recovered log missing argument (#90436) --- homeassistant/components/bluetooth/active_update_coordinator.py | 2 +- homeassistant/components/bluetooth/active_update_processor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index d5cf65d872..6d4e67119d 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -143,7 +143,7 @@ class ActiveBluetoothDataUpdateCoordinator( self._last_poll = monotonic_time_coarse() if not self.last_poll_successful: - self.logger.debug("%s: Polling recovered") + self.logger.debug("%s: Polling recovered", self.address) self.last_poll_successful = True self._async_handle_bluetooth_poll() diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index aabc27ff14..b450c61225 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -136,7 +136,7 @@ class ActiveBluetoothProcessorCoordinator( self._last_poll = monotonic_time_coarse() if not self.last_poll_successful: - self.logger.debug("%s: Polling recovered") + self.logger.debug("%s: Polling recovered", self.address) self.last_poll_successful = True for processor in self._processors: From ce28bfe5b21a2364727f3722f200546461ac0f77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:01:11 -1000 Subject: [PATCH 0293/1165] Remove unused types argument in statistics query generation (#90431) * Remove unused types argument in statistics query generation * update test --- homeassistant/components/recorder/statistics.py | 3 +-- tests/components/recorder/test_statistics.py | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8025616d24..9f78b0534b 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1034,7 +1034,6 @@ def _generate_statistics_during_period_stmt( end_time: datetime | None, metadata_ids: list[int] | None, table: type[StatisticsBase], - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Prepare a database query for statistics during a given period. @@ -1535,7 +1534,7 @@ def _statistics_during_period_with_session( if "sum" in types: columns = columns.add_columns(table.sum) stmt = _generate_statistics_during_period_stmt( - columns, start_time, end_time, metadata_ids, table, types + columns, start_time, end_time, metadata_ids, table ) stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ebad039ca4..ff42979431 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1246,11 +1246,11 @@ def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, {} + columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm ) cache_key_1 = stmt._generate_cache_key() stmt2 = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, {} + columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 @@ -1266,7 +1266,6 @@ def test_cache_key_for_generate_statistics_during_period_stmt() -> None: dt_util.utcnow(), [0], StatisticsShortTerm, - {"max", "mean"}, ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 From 403dffc12d40aae08f8f6e82a12eb79a9b71412b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:28:24 -1000 Subject: [PATCH 0294/1165] Reduce cache key size for queries that only need single columns (#90430) * Reduce cache key size for queries that only need single columns These queries only cared about a single row but would select the whole set of columns from the orm object * wrap it --- homeassistant/components/recorder/migration.py | 6 +++++- homeassistant/components/recorder/statistics.py | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0eee065a0c..4be0132765 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -143,7 +143,11 @@ def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str]) def _get_schema_version(session: Session) -> int | None: """Get the schema version.""" - res = session.query(SchemaChanges).order_by(SchemaChanges.change_id.desc()).first() + res = ( + session.query(SchemaChanges.schema_version) + .order_by(SchemaChanges.change_id.desc()) + .first() + ) return getattr(res, "schema_version", None) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 9f78b0534b..0122ba4464 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -480,6 +480,11 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - return True +def _get_first_id_stmt(start: datetime) -> StatementLambdaElement: + """Return a statement that returns the first run_id at start.""" + return lambda_stmt(lambda: select(StatisticsRuns.run_id).filter_by(start=start)) + + def _compile_statistics( instance: Recorder, session: Session, start: datetime, fire_events: bool ) -> set[str]: @@ -496,7 +501,7 @@ def _compile_statistics( modified_statistic_ids: set[str] = set() # Return if we already have 5-minute statistics for the requested period - if session.query(StatisticsRuns).filter_by(start=start).first(): + if execute_stmt_lambda_element(session, _get_first_id_stmt(start)): _LOGGER.debug("Statistics already compiled for %s-%s", start, end) return modified_statistic_ids From 885be98f8fcc279840cbdb6d5850231d2076e328 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Mar 2023 23:37:43 -0400 Subject: [PATCH 0295/1165] OpenAI to use GPT3.5 (#90423) * OpenAI to use GPT3.5 * Add snapshot --- .../openai_conversation/__init__.py | 42 ++++++---------- .../openai_conversation/config_flow.py | 41 +++++++++++----- .../components/openai_conversation/const.py | 8 +--- .../openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_init.ambr | 34 +++++++++++++ .../openai_conversation/test_config_flow.py | 6 +-- .../openai_conversation/test_init.py | 48 ++++++++----------- 9 files changed, 107 insertions(+), 78 deletions(-) create mode 100644 tests/components/openai_conversation/snapshots/test_init.ambr diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 355b7764b0..3e67d4e27d 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -16,13 +16,13 @@ from homeassistant.helpers import area_registry as ar, intent, template from homeassistant.util import ulid from .const import ( + CONF_CHAT_MODEL, CONF_MAX_TOKENS, - CONF_MODEL, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_CHAT_MODEL, DEFAULT_MAX_TOKENS, - DEFAULT_MODEL, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_P, @@ -63,7 +63,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): """Initialize the agent.""" self.hass = hass self.entry = entry - self.history: dict[str, str] = {} + self.history: dict[str, list[dict]] = {} @property def attribution(self): @@ -75,14 +75,14 @@ class OpenAIAgent(conversation.AbstractConversationAgent): ) -> conversation.ConversationResult: """Process a sentence.""" raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT) - model = self.entry.options.get(CONF_MODEL, DEFAULT_MODEL) + model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) max_tokens = self.entry.options.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS) top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P) temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) if user_input.conversation_id in self.history: conversation_id = user_input.conversation_id - prompt = self.history[conversation_id] + messages = self.history[conversation_id] else: conversation_id = ulid.ulid() try: @@ -97,25 +97,16 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) + messages = [{"role": "system", "content": prompt}] - user_name = "User" - if ( - user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - and user.name - ): - user_name = user.name + messages.append({"role": "user", "content": user_input.text}) - prompt += f"\n{user_name}: {user_input.text}\nSmart home: " - - _LOGGER.debug("Prompt for %s: %s", model, prompt) + _LOGGER.debug("Prompt for %s: %s", model, messages) try: - result = await openai.Completion.acreate( - engine=model, - prompt=prompt, + result = await openai.ChatCompletion.acreate( + model=model, + messages=messages, max_tokens=max_tokens, top_p=top_p, temperature=temperature, @@ -132,15 +123,12 @@ class OpenAIAgent(conversation.AbstractConversationAgent): ) _LOGGER.debug("Response %s", result) - response = result["choices"][0]["text"].strip() - self.history[conversation_id] = prompt + response - - stripped_response = response - if response.startswith("Smart home:"): - stripped_response = response[11:].strip() + response = result["choices"][0]["message"] + messages.append(response) + self.history[conversation_id] = messages intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(stripped_response) + intent_response.async_set_speech(response["content"]) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 2db5e98a1f..892d794bca 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -22,13 +22,13 @@ from homeassistant.helpers.selector import ( ) from .const import ( + CONF_CHAT_MODEL, CONF_MAX_TOKENS, - CONF_MODEL, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_CHAT_MODEL, DEFAULT_MAX_TOKENS, - DEFAULT_MODEL, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_P, @@ -46,7 +46,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( DEFAULT_OPTIONS = types.MappingProxyType( { CONF_PROMPT: DEFAULT_PROMPT, - CONF_MODEL: DEFAULT_MODEL, + CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL, CONF_MAX_TOKENS: DEFAULT_MAX_TOKENS, CONF_TOP_P: DEFAULT_TOP_P, CONF_TEMPERATURE: DEFAULT_TEMPERATURE, @@ -131,13 +131,32 @@ def openai_config_option_schema(options: MappingProxyType[str, Any]) -> dict: if not options: options = DEFAULT_OPTIONS return { - vol.Required(CONF_PROMPT, default=options.get(CONF_PROMPT)): TemplateSelector(), - vol.Required(CONF_MODEL, default=options.get(CONF_MODEL)): str, - vol.Required(CONF_MAX_TOKENS, default=options.get(CONF_MAX_TOKENS)): int, - vol.Required(CONF_TOP_P, default=options.get(CONF_TOP_P)): NumberSelector( - NumberSelectorConfig(min=0, max=1, step=0.05) - ), - vol.Required( - CONF_TEMPERATURE, default=options.get(CONF_TEMPERATURE) + vol.Optional( + CONF_PROMPT, + description={"suggested_value": options[CONF_PROMPT]}, + default=DEFAULT_PROMPT, + ): TemplateSelector(), + vol.Optional( + CONF_CHAT_MODEL, + description={ + # New key in HA 2023.4 + "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + }, + default=DEFAULT_CHAT_MODEL, + ): str, + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options[CONF_MAX_TOKENS]}, + default=DEFAULT_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options[CONF_TOP_P]}, + default=DEFAULT_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options[CONF_TEMPERATURE]}, + default=DEFAULT_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), } diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index ed914efeb6..88289eb90b 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -22,13 +22,9 @@ An overview of the areas and the devices in this smart home: Answer the user's questions about the world truthfully. If the user wants to control a device, reject the request and suggest using the Home Assistant app. - -Now finish this conversation: - -Smart home: How can I assist? """ -CONF_MODEL = "model" -DEFAULT_MODEL = "text-davinci-003" +CONF_CHAT_MODEL = "chat_model" +DEFAULT_CHAT_MODEL = "gpt-3.5-turbo" CONF_MAX_TOKENS = "max_tokens" DEFAULT_MAX_TOKENS = 150 CONF_TOP_P = "top_p" diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 0e245eb78b..88d347355e 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==0.26.2"] + "requirements": ["openai==0.27.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 49ac9171ec..aeb7033d0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1269,7 +1269,7 @@ open-garage==0.2.0 open-meteo==0.2.1 # homeassistant.components.openai_conversation -openai==0.26.2 +openai==0.27.2 # homeassistant.components.opencv # opencv-python-headless==4.6.0.66 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4213338da8..a21ef8b32b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ open-garage==0.2.0 open-meteo==0.2.1 # homeassistant.components.openai_conversation -openai==0.26.2 +openai==0.27.2 # homeassistant.components.openerz openerz-api==0.2.0 diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr new file mode 100644 index 0000000000..bc06f51f41 --- /dev/null +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_default_prompt + list([ + dict({ + 'content': ''' + This smart home is controlled by Home Assistant. + + An overview of the areas and the devices in this smart home: + + Test Area: + - Test Device (Test Model) + + Test Area 2: + - Test Device 2 + - Test Device 3 (Test Model 3A) + - Test Device 4 + - 1 (3) + + Answer the user's questions about the world truthfully. + + If the user wants to control a device, reject the request and suggest using the Home Assistant app. + ''', + 'role': 'system', + }), + dict({ + 'content': 'hello', + 'role': 'user', + }), + dict({ + 'content': 'Hello, how can I help you?', + 'role': 'assistant', + }), + ]) +# --- diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 25849882e8..4ce677d8cc 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -6,8 +6,8 @@ import pytest from homeassistant import config_entries from homeassistant.components.openai_conversation.const import ( - CONF_MODEL, - DEFAULT_MODEL, + CONF_CHAT_MODEL, + DEFAULT_CHAT_MODEL, DOMAIN, ) from homeassistant.core import HomeAssistant @@ -72,7 +72,7 @@ async def test_options( assert options["type"] == FlowResultType.CREATE_ENTRY assert options["data"]["prompt"] == "Speak like a pirate" assert options["data"]["max_tokens"] == 200 - assert options["data"][CONF_MODEL] == DEFAULT_MODEL + assert options["data"][CONF_CHAT_MODEL] == DEFAULT_CHAT_MODEL @pytest.mark.parametrize( diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 3b78a90f40..144d77beab 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import patch from openai import error +from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant @@ -15,6 +16,7 @@ async def test_default_prompt( mock_init_component, area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test that the default prompt works.""" for i in range(3): @@ -86,40 +88,30 @@ async def test_default_prompt( model=3, suggested_area="Test Area 2", ) - with patch("openai.Completion.acreate") as mock_create: + with patch( + "openai.ChatCompletion.acreate", + return_value={ + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello, how can I help you?", + } + } + ] + }, + ) as mock_create: result = await conversation.async_converse(hass, "hello", None, Context()) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert ( - mock_create.mock_calls[0][2]["prompt"] - == """This smart home is controlled by Home Assistant. - -An overview of the areas and the devices in this smart home: - -Test Area: -- Test Device (Test Model) - -Test Area 2: -- Test Device 2 -- Test Device 3 (Test Model 3A) -- Test Device 4 -- 1 (3) - -Answer the user's questions about the world truthfully. - -If the user wants to control a device, reject the request and suggest using the Home Assistant app. - -Now finish this conversation: - -Smart home: How can I assist? -User: hello -Smart home: """ - ) + assert mock_create.mock_calls[0][2]["messages"] == snapshot async def test_error_handling(hass: HomeAssistant, mock_init_component) -> None: """Test that the default prompt works.""" - with patch("openai.Completion.acreate", side_effect=error.ServiceUnavailableError): + with patch( + "openai.ChatCompletion.acreate", side_effect=error.ServiceUnavailableError + ): result = await conversation.async_converse(hass, "hello", None, Context()) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -138,7 +130,7 @@ async def test_template_error( ) with patch( "openai.Engine.list", - ), patch("openai.Completion.acreate"): + ), patch("openai.ChatCompletion.acreate"): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse(hass, "hello", None, Context()) From 5dc96a6952edf659c9037cec7d2313748759f51f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:52:44 -1000 Subject: [PATCH 0296/1165] Fix unbound variable in sql when session setup fails (#90439) Traceback (most recent call last): File "/Users/bdraco/home-assistant/homeassistant/helpers/entity_platform.py", line 304, in _async_setup_platform await asyncio.shield(task) File "/Users/bdraco/home-assistant/homeassistant/components/sql/sensor.py", line 75, in async_setup_platform await async_setup_sensor( File "/Users/bdraco/home-assistant/homeassistant/components/sql/sensor.py", line 150, in async_setup_sensor sessmaker := await hass.async_add_executor_job( File "/opt/homebrew/Cellar/python@3.10/3.10.9/Frameworks/Python.framework/Versions/3.10/lib/python3.10/concurrent/futures/thread.py", line 58, in run result = self.fn(*self.args, **self.kwargs) File "/Users/bdraco/home-assistant/homeassistant/components/sql/sensor.py", line 205, in _validate_and_get_session_maker_for_db_url if sess: UnboundLocalError: local variable 'sess' referenced before assignment --- homeassistant/components/sql/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 57818ef27e..93d3e7be35 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -180,11 +180,12 @@ def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | This does I/O and should be run in the executor. """ + sess: Session | None = None try: engine = sqlalchemy.create_engine(db_url, future=True) sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) # Run a dummy query just to test the db_url - sess: Session = sessmaker() + sess = sessmaker() sess.execute(sqlalchemy.text("SELECT 1;")) except SQLAlchemyError as err: From 8096be768d1098547dcdfff813158cd94d9acf5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:54:03 -1000 Subject: [PATCH 0297/1165] Isolate the sql integration with a separate query cache (#90438) * Isolate the sql integration with a seperate query cache If there were a lot of sql integrations they could affect the performance of the recorder/logbook/history since they were sharing the same LRU and since the sql sensor updates frequently it would evict the recorder queries from the LRU. * generate in stmt * avoid double gen * Revert "avoid double gen" This reverts commit 6a5aa65268da12e2cd0e73e0bfb46db6e7e6214d. --- homeassistant/components/sql/sensor.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 93d3e7be35..c19c2c258b 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -6,9 +6,12 @@ import decimal import logging import sqlalchemy +from sqlalchemy import lambda_stmt from sqlalchemy.engine import Result from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.util import LRUCache from homeassistant.components.recorder import CONF_DB_URL, get_instance from homeassistant.components.sensor import ( @@ -38,6 +41,8 @@ from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) +_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) + def redact_credentials(data: str) -> str: """Redact credentials from string data.""" @@ -202,6 +207,12 @@ def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | sess.close() +def _generate_lambda_stmt(query: str) -> StatementLambdaElement: + """Generate the lambda statement.""" + text = sqlalchemy.text(query) + return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE) + + class SQLSensor(SensorEntity): """Representation of an SQL sensor.""" @@ -234,6 +245,7 @@ class SQLSensor(SensorEntity): self._attr_extra_state_attributes = {} self._attr_unique_id = unique_id self._use_database_executor = use_database_executor + self._lambda_stmt = _generate_lambda_stmt(query) if not yaml and unique_id: self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -255,7 +267,7 @@ class SQLSensor(SensorEntity): self._attr_extra_state_attributes = {} sess: scoped_session = self.sessionmaker() try: - result: Result = sess.execute(sqlalchemy.text(self._query)) + result: Result = sess.execute(self._lambda_stmt) except SQLAlchemyError as err: _LOGGER.error( "Error executing query %s: %s", From 12edaa052c26c258d4c6bb938f1a19cddd159797 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 29 Mar 2023 08:29:44 +0200 Subject: [PATCH 0298/1165] Fix data issue for energyzero during midnight (#90433) --- homeassistant/components/energyzero/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 8bdfb36ad7..05d23ca446 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==0.3.1"] + "requirements": ["energyzero==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index aeb7033d0a..ef36e83a15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -652,7 +652,7 @@ emulated_roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.3.1 +energyzero==0.4.1 # homeassistant.components.enocean enocean==0.50 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a21ef8b32b..0783ab3fc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -511,7 +511,7 @@ emulated_roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.3.1 +energyzero==0.4.1 # homeassistant.components.enocean enocean==0.50 From 0327f312f280d43132764fe4a70b3bf01089ee40 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 29 Mar 2023 08:37:20 +0200 Subject: [PATCH 0299/1165] Fix data issue for easyEnergy during midnight (#90434) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 0954269628..803530fd6f 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.2.2"] + "requirements": ["easyenergy==0.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ef36e83a15..c331110390 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.2 +easyenergy==0.2.3 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0783ab3fc8..11c99c5896 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,7 +493,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.2 +easyenergy==0.2.3 # homeassistant.components.elgato elgato==4.0.1 From b58c90602fa73b1d9b528834bd083712c9fa5d42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 21:39:44 -1000 Subject: [PATCH 0300/1165] Bump yalexs-ble to 2.1.13 (#90442) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 5528b79353..07ecc2a1be 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.12"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.13"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 6cff0dd8c6..7c45f309e6 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.12"] + "requirements": ["yalexs-ble==2.1.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index c331110390..ea3c58add0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.12 +yalexs-ble==2.1.13 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11c99c5896..3db436ada3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1911,7 +1911,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.12 +yalexs-ble==2.1.13 # homeassistant.components.august yalexs==1.2.7 From c06bc28434233f29d78f257fcf8d5684c4d924ac Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Wed, 29 Mar 2023 04:34:29 -0400 Subject: [PATCH 0301/1165] Limit observations requested for NWS (#90137) * fetch data only for 70 minutes * Use timezone aware now * Type hint Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/nws/__init__.py | 7 ++++++- homeassistant/components/nws/const.py | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index fed7642605..ef0731ee94 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -26,6 +26,7 @@ from .const import ( COORDINATOR_OBSERVATION, DOMAIN, NWS_DATA, + UPDATE_TIME_PERIOD, ) _LOGGER = logging.getLogger(__name__) @@ -110,11 +111,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nws_data = SimpleNWS(latitude, longitude, api_key, client_session) await nws_data.set_station(station) + async def update_observation() -> None: + """Retrieve recent observations.""" + await nws_data.update_observation(start_time=utcnow() - UPDATE_TIME_PERIOD) + coordinator_observation = NwsDataUpdateCoordinator( hass, _LOGGER, name=f"NWS observation station {station}", - update_method=nws_data.update_observation, + update_method=update_observation, update_interval=DEFAULT_SCAN_INTERVAL, failed_update_interval=FAILED_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 96844edd80..109af7a565 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -82,3 +82,5 @@ COORDINATOR_FORECAST_HOURLY = "coordinator_forecast_hourly" OBSERVATION_VALID_TIME = timedelta(minutes=20) FORECAST_VALID_TIME = timedelta(minutes=45) +# A lot of stations update once hourly plus some wiggle room +UPDATE_TIME_PERIOD = timedelta(minutes=70) From 8dbcbd156adb3fa1f9c2e70e90936422313faa98 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 29 Mar 2023 09:33:27 +0000 Subject: [PATCH 0302/1165] Add new sensors to airq (#90413) Support for the sensors introduced in air-Q firmware v1.82.0 --- homeassistant/components/airq/sensor.py | 130 +++++++++++++++++++++++- 1 file changed, 125 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index a47c308279..7f0d51fcaa 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -51,6 +51,13 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) # Keys must match those in the data dictionary SENSOR_TYPES: list[AirQEntityDescription] = [ + AirQEntityDescription( + key="c2h4o", + name="Acetaldehyde", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("c2h4o"), + ), AirQEntityDescription( key="nh3_MR100", name="Ammonia", @@ -58,6 +65,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("nh3_MR100"), ), + AirQEntityDescription( + key="ash3", + name="Arsine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ash3"), + ), + AirQEntityDescription( + key="br2", + name="Bromine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("br2"), + ), + AirQEntityDescription( + key="ch4s", + name="CH4S", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ch4s"), + ), AirQEntityDescription( key="cl2_M20", name="Chlorine", @@ -65,6 +93,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("cl2_M20"), ), + AirQEntityDescription( + key="clo2", + name="ClO2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("clo2"), + ), AirQEntityDescription( key="co", name="CO", @@ -80,6 +115,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("co2"), ), + AirQEntityDescription( + key="cs2", + name="CS2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("cs2"), + ), AirQEntityDescription( key="dewpt", name="Dew point", @@ -95,6 +137,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ethanol"), ), + AirQEntityDescription( + key="c2h4", + name="Ethylene", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("c2h4"), + ), AirQEntityDescription( key="ch2o_M10", name="Formaldehyde", @@ -102,6 +151,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ch2o_M10"), ), + AirQEntityDescription( + key="f2", + name="Fluorine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("f2"), + ), AirQEntityDescription( key="h2s", name="H2S", @@ -109,6 +165,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2s"), ), + AirQEntityDescription( + key="hcl", + name="HCl", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hcl"), + ), + AirQEntityDescription( + key="hcn", + name="HCN", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hcn"), + ), + AirQEntityDescription( + key="hf", + name="HF", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hf"), + ), AirQEntityDescription( key="health", name="Health Index", @@ -140,6 +217,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2_M1000"), ), + AirQEntityDescription( + key="h2o2", + name="Hydrogen peroxide", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("h2o2"), + ), AirQEntityDescription( key="ch4_MIPEX", name="Methane", @@ -172,12 +256,11 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ value=lambda data: data.get("no2"), ), AirQEntityDescription( - key="o3", - name="Ozone", - device_class=SensorDeviceClass.OZONE, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + key="acid_M100", + name="Organic acid", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, - value=lambda data: data.get("o3"), + value=lambda data: data.get("acid_M100"), ), AirQEntityDescription( key="oxygen", @@ -187,6 +270,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ value=lambda data: data.get("oxygen"), icon="mdi:leaf", ), + AirQEntityDescription( + key="o3", + name="Ozone", + device_class=SensorDeviceClass.OZONE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("o3"), + ), AirQEntityDescription( key="performance", name="Performance Index", @@ -195,6 +286,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ icon="mdi:head-check", value=lambda data: data.get("performance", 0.0) / 10.0, ), + AirQEntityDescription( + key="ph3", + name="PH3", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ph3"), + ), AirQEntityDescription( key="pm1", name="PM1", @@ -245,6 +343,20 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("c3h8_MIPEX"), ), + AirQEntityDescription( + key="refigerant", + name="Refrigerant", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("refigerant"), + ), + AirQEntityDescription( + key="sih4", + name="SiH4", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("sih4"), + ), AirQEntityDescription( key="so2", name="SO2", @@ -299,6 +411,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc_ionsc"), ), + AirQEntityDescription( + key="virus", + name="Virus Index", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:virus-off", + value=lambda data: data.get("virus", 0.0), + ), ] From d427c35c871ff3e4be4adbc803d7bfc9677b624c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Mar 2023 14:41:38 +0200 Subject: [PATCH 0303/1165] Reolink improve config flow login (#90036) --- .../components/reolink/config_flow.py | 5 ++++- homeassistant/components/reolink/host.py | 18 ++++++++++-------- homeassistant/components/reolink/strings.json | 4 ++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 15f3dfa613..a29871f28d 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -108,7 +108,10 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" errors = {} - placeholders = {"error": ""} + placeholders = { + "error": "", + "troubleshooting_link": "https://www.home-assistant.io/integrations/reolink/#troubleshooting", + } if user_input is not None: if CONF_HOST not in user_input: diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 1c0f97b6a2..f781074648 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -82,9 +82,15 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + enable_rtsp = None enable_onvif = None enable_rtmp = None - enable_rtsp = None + + if not self._api.rtsp_enabled: + _LOGGER.debug( + "RTSP is disabled on %s, trying to enable it", self._api.nvr_name + ) + enable_rtsp = True if not self._api.onvif_enabled: _LOGGER.debug( @@ -97,11 +103,6 @@ class ReolinkHost: "RTMP is disabled on %s, trying to enable it", self._api.nvr_name ) enable_rtmp = True - elif not self._api.rtsp_enabled and self._api.protocol == "rtsp": - _LOGGER.debug( - "RTSP is disabled on %s, trying to enable it", self._api.nvr_name - ) - enable_rtsp = True if enable_onvif or enable_rtmp or enable_rtsp: try: @@ -112,13 +113,14 @@ class ReolinkHost: ) except ReolinkError: ports = "" + if enable_rtsp: + ports += "RTSP " + if enable_onvif: ports += "ONVIF " if enable_rtmp: ports += "RTMP " - elif enable_rtsp: - ports += "RTSP " ir.async_create_issue( self._hass, diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 50c561530e..c36001e037 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -3,7 +3,7 @@ "flow_title": "{hostname} ({ip_address})", "step": { "user": { - "description": "{error}", + "description": "See the [troubleshooting steps]({troubleshooting_link}) if you encounter problems. {error}", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -19,7 +19,7 @@ }, "error": { "api_error": "API error occurred", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%], check the IP address of the camera and see the troubleshooting steps in the documentation", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''", "unknown": "[%key:common::config_flow::error::unknown%]" From a1c94919ded23ae1201e891ddff48620adaefc4b Mon Sep 17 00:00:00 2001 From: mletenay Date: Wed, 29 Mar 2023 16:01:14 +0200 Subject: [PATCH 0304/1165] Fix goodwe export limit unit on single phase DT inverters (#90427) * Fix export limit unit on single phase DT inverters * Update homeassistant/components/goodwe/number.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/goodwe/manifest.json | 2 +- homeassistant/components/goodwe/number.py | 13 +++++++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 53e093758c..8dad8454d6 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.25"] + "requirements": ["goodwe==0.2.29"] } diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index 9f997daec4..3f9714aa37 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -39,8 +39,13 @@ class GoodweNumberEntityDescription( """Class describing Goodwe number entities.""" +def _get_setting_unit(inverter: Inverter, setting: str) -> str: + """Return the unit of an inverter setting.""" + return next((s.unit for s in inverter.settings() if s.id_ == setting), "") + + NUMBERS = ( - # non DT inverters (limit in W) + # Export limit in W GoodweNumberEntityDescription( key="grid_export_limit", name="Grid export limit", @@ -53,9 +58,9 @@ NUMBERS = ( native_max_value=10000, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), - filter=lambda inv: type(inv).__name__ != "DT", + filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") != "%", ), - # DT inverters (limit is in %) + # Export limit in % GoodweNumberEntityDescription( key="grid_export_limit", name="Grid export limit", @@ -67,7 +72,7 @@ NUMBERS = ( native_max_value=100, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), - filter=lambda inv: type(inv).__name__ == "DT", + filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") == "%", ), GoodweNumberEntityDescription( key="battery_discharge_depth", diff --git a/requirements_all.txt b/requirements_all.txt index ea3c58add0..f0c54881fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -798,7 +798,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.25 +goodwe==0.2.29 # homeassistant.components.google_mail google-api-python-client==2.71.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3db436ada3..ea2d06816e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -614,7 +614,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.25 +goodwe==0.2.29 # homeassistant.components.google_mail google-api-python-client==2.71.0 From f7925763a46a52cf7f6191285a309d7d2030b906 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:20:51 +0200 Subject: [PATCH 0305/1165] Make abort_entries_match available in options flow (#90406) * Make abort_entries_match available in options flow * Add tests * Exclude ignore entries and add test * Move to OptionsFlow * Adjust tests * Use mock_config_flow * Use AbortFlow * Remove duplicate code --- homeassistant/components/imap/config_flow.py | 67 ++++++-------- homeassistant/config_entries.py | 58 +++++++++--- tests/test_config_entries.py | 93 ++++++++++++++++++++ 3 files changed, 167 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index c855d099b4..8dd3019878 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv from .const import ( @@ -148,50 +148,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): """Option flow handler.""" - def _async_abort_entries_match( - self, match_dict: dict[str, Any] | None - ) -> dict[str, str]: - """Validate the user input against other config entries.""" - if match_dict is None: - return {} - - errors: dict[str, str] = {} - for entry in [ - entry - for entry in self.hass.config_entries.async_entries(DOMAIN) - if entry is not self.config_entry - ]: - if all(item in entry.data.items() for item in match_dict.items()): - errors["base"] = "already_configured" - break - return errors - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the options.""" - errors: dict[str, str] = self._async_abort_entries_match( - { - CONF_SERVER: self._config_entry.data[CONF_SERVER], - CONF_USERNAME: self._config_entry.data[CONF_USERNAME], - CONF_FOLDER: user_input[CONF_FOLDER], - CONF_SEARCH: user_input[CONF_SEARCH], - } - if user_input - else None - ) + errors: dict[str, str] | None = None entry_data: dict[str, Any] = dict(self._config_entry.data) - if not errors and user_input is not None: - entry_data.update(user_input) - errors = await validate_input(entry_data) - if not errors: - self.hass.config_entries.async_update_entry( - self.config_entry, data=entry_data + if user_input is not None: + try: + self._async_abort_entries_match( + { + CONF_SERVER: self._config_entry.data[CONF_SERVER], + CONF_USERNAME: self._config_entry.data[CONF_USERNAME], + CONF_FOLDER: user_input[CONF_FOLDER], + CONF_SEARCH: user_input[CONF_SEARCH], + } + if user_input + else None ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id) - ) - return self.async_create_entry(data={}) + except AbortFlow as err: + errors = {"base": err.reason} + else: + entry_data.update(user_input) + errors = await validate_input(entry_data) + if not errors: + self.hass.config_entries.async_update_entry( + self.config_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self.config_entry.entry_id + ) + ) + return self.async_create_entry(data={}) schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b21ae391e2..454cfeade2 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1468,6 +1468,28 @@ async def _old_conf_migrator(old_config: dict[str, Any]) -> dict[str, Any]: return {"entries": old_config} +@callback +def _async_abort_entries_match( + other_entries: list[ConfigEntry], match_dict: dict[str, Any] | None = None +) -> None: + """Abort if current entries match all data. + + Requires `already_configured` in strings.json in user visible flows. + """ + if match_dict is None: + match_dict = {} # Match any entry + for entry in other_entries: + if all( + item + in ChainMap( + entry.options, # type: ignore[arg-type] + entry.data, # type: ignore[arg-type] + ).items() + for item in match_dict.items() + ): + raise data_entry_flow.AbortFlow("already_configured") + + class ConfigFlow(data_entry_flow.FlowHandler): """Base class for config flows with some helpers.""" @@ -1505,18 +1527,9 @@ class ConfigFlow(data_entry_flow.FlowHandler): Requires `already_configured` in strings.json in user visible flows. """ - if match_dict is None: - match_dict = {} # Match any entry - for entry in self._async_current_entries(include_ignore=False): - if all( - item - in ChainMap( - entry.options, # type: ignore[arg-type] - entry.data, # type: ignore[arg-type] - ).items() - for item in match_dict.items() - ): - raise data_entry_flow.AbortFlow("already_configured") + _async_abort_entries_match( + self._async_current_entries(include_ignore=False), match_dict + ) @callback def _abort_if_unique_id_configured( @@ -1858,6 +1871,27 @@ class OptionsFlow(data_entry_flow.FlowHandler): handler: str + @callback + def _async_abort_entries_match( + self, match_dict: dict[str, Any] | None = None + ) -> None: + """Abort if another current entry matches all data. + + Requires `already_configured` in strings.json in user visible flows. + """ + + config_entry = cast( + ConfigEntry, self.hass.config_entries.async_get_entry(self.handler) + ) + _async_abort_entries_match( + [ + entry + for entry in self.hass.config_entries.async_entries(config_entry.domain) + if entry is not config_entry and entry.source != SOURCE_IGNORE + ], + match_dict, + ) + class OptionsFlowWithConfigEntry(OptionsFlow): """Base class for options flows with config entry and options.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index c8cdc56198..60b9a250c1 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -40,6 +40,7 @@ from .common import ( MockModule, MockPlatform, async_fire_time_changed, + mock_config_flow, mock_coro, mock_entity_platform, mock_integration, @@ -3388,6 +3389,98 @@ async def test__async_abort_entries_match( assert result["reason"] == reason +@pytest.mark.parametrize( + ("matchers", "reason"), + [ + ({}, "already_configured"), + ({"host": "3.3.3.3"}, "no_match"), + ({"vendor": "no_match"}, "no_match"), + ({"host": "3.4.5.6"}, "already_configured"), + ({"host": "3.4.5.6", "ip": "3.4.5.6"}, "no_match"), + ({"host": "3.4.5.6", "ip": "1.2.3.4"}, "already_configured"), + ({"host": "3.4.5.6", "ip": "1.2.3.4", "port": 23}, "already_configured"), + ( + {"host": "9.9.9.9", "ip": "6.6.6.6", "port": 12, "vendor": "zoo"}, + "already_configured", + ), + ({"vendor": "zoo"}, "already_configured"), + ({"ip": "9.9.9.9"}, "already_configured"), + ({"ip": "7.7.7.7"}, "no_match"), # ignored + ({"vendor": "data"}, "no_match"), + ( + {"vendor": "options"}, + "already_configured", + ), # ensure options takes precedence over data + ], +) +async def test__async_abort_entries_match_options_flow( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + matchers: dict[str, str], + reason: str, +) -> None: + """Test aborting if matching config entries exist.""" + MockConfigEntry( + domain="test_abort", data={"ip": "1.2.3.4", "host": "4.5.6.7", "port": 23} + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", data={"ip": "9.9.9.9", "host": "4.5.6.7", "port": 23} + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", data={"ip": "1.2.3.4", "host": "3.4.5.6", "port": 23} + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", + source=config_entries.SOURCE_IGNORE, + data={"ip": "7.7.7.7", "host": "4.5.6.7", "port": 23}, + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", + data={"ip": "6.6.6.6", "host": "9.9.9.9", "port": 12}, + options={"vendor": "zoo"}, + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", + data={"vendor": "data"}, + options={"vendor": "options"}, + ).add_to_hass(hass) + + original_entry = MockConfigEntry(domain="test_abort", data={}) + original_entry.add_to_hass(hass) + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule("test_abort", async_setup_entry=mock_setup_entry)) + mock_entity_platform(hass, "config_flow.test_abort", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + class _OptionsFlow(config_entries.OptionsFlow): + """Test flow.""" + + async def async_step_init(self, user_input=None): + """Test user step.""" + if errors := self._async_abort_entries_match(user_input): + return self.async_abort(reason=errors["base"]) + return self.async_abort(reason="no_match") + + return _OptionsFlow() + + with mock_config_flow("test_abort", TestFlow): + result = await hass.config_entries.options.async_init( + original_entry.entry_id, data=matchers + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + async def test_loading_old_data( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: From 81c39e42f49b23a82e667421d222e365f9765bdf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Mar 2023 12:25:08 -0400 Subject: [PATCH 0306/1165] Bump home-assistant-intents to 2023.3.29 (#90459) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 7630eed01f..0753fcd5af 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.2.28"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.3.29"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dea105f296..7a1ae9b5c4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230309.1 -home-assistant-intents==2023.2.28 +home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index f0c54881fc..15553abc14 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -910,7 +910,7 @@ holidays==0.21.13 home-assistant-frontend==20230309.1 # homeassistant.components.conversation -home-assistant-intents==2023.2.28 +home-assistant-intents==2023.3.29 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea2d06816e..cd04c60f15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -696,7 +696,7 @@ holidays==0.21.13 home-assistant-frontend==20230309.1 # homeassistant.components.conversation -home-assistant-intents==2023.2.28 +home-assistant-intents==2023.3.29 # homeassistant.components.home_connect homeconnect==0.7.2 From 7ca5beddfc750b4215ef721409cf3aa7a281fe1a Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 29 Mar 2023 12:36:01 -0400 Subject: [PATCH 0307/1165] Fix Oralb Logger (#90460) --- homeassistant/components/oralb/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index a1071cc0a1..adf72f5fe5 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -11,6 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/oralb", "iot_class": "local_push", - "loggers": ["oralb-ble"], + "loggers": ["oralb_ble"], "requirements": ["oralb-ble==0.17.6"] } From a33c70e59557eecfb376508b5ad3969d88f4c999 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Mar 2023 18:52:21 +0200 Subject: [PATCH 0308/1165] Bump python-matter-server to 3.2.0 (#90457) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index b81ac2c62b..190bf33dcf 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==3.1.0"] + "requirements": ["python-matter-server==3.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 15553abc14..ea4a5c918a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2078,7 +2078,7 @@ python-kasa==0.5.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==3.1.0 +python-matter-server==3.2.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd04c60f15..daa52bc318 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1492,7 +1492,7 @@ python-juicenet==1.1.0 python-kasa==0.5.1 # homeassistant.components.matter -python-matter-server==3.1.0 +python-matter-server==3.2.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From b881995efc82fbe71fecfdb8af7b87ab2e868436 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Mar 2023 19:36:42 +0200 Subject: [PATCH 0309/1165] Add verify ssl option to nextcloud (#90462) add verify sssl option to config flow --- homeassistant/components/nextcloud/__init__.py | 6 +++++- homeassistant/components/nextcloud/config_flow.py | 7 +++++-- homeassistant/components/nextcloud/const.py | 1 + homeassistant/components/nextcloud/strings.json | 3 ++- .../components/nextcloud/snapshots/test_config_flow.ambr | 2 ++ tests/components/nextcloud/test_config_flow.py | 9 +++++++-- 6 files changed, 22 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index d2514b9091..60489b3e30 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -10,6 +10,7 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, Platform, ) from homeassistant.core import HomeAssistant @@ -73,7 +74,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _connect_nc(): return NextcloudMonitor( - entry.data[CONF_URL], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + entry.data[CONF_URL], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_VERIFY_SSL], ) try: diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index e297a6893a..f22d0a01a5 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -8,16 +8,17 @@ from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.data_entry_flow import FlowResult -from .const import DOMAIN +from .const import DEFAULT_VERIFY_SSL, DOMAIN DATA_SCHEMA_USER = vol.Schema( { vol.Required(CONF_URL): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, } ) _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,7 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_URL], user_input[CONF_USERNAME], user_input[CONF_PASSWORD], + user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), ) async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: @@ -51,6 +53,7 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): CONF_URL: user_input[CONF_URL], CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_USERNAME: user_input[CONF_USERNAME], + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, } ) diff --git a/homeassistant/components/nextcloud/const.py b/homeassistant/components/nextcloud/const.py index 223d21771b..248128dd53 100644 --- a/homeassistant/components/nextcloud/const.py +++ b/homeassistant/components/nextcloud/const.py @@ -3,3 +3,4 @@ from datetime import timedelta DOMAIN = "nextcloud" DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_VERIFY_SSL = True diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index 9ae7ed24a6..dc0175ea8e 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -7,7 +7,8 @@ "data": { "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } } }, diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr index 0c9df1238c..caa9528507 100644 --- a/tests/components/nextcloud/snapshots/test_config_flow.ambr +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -4,6 +4,7 @@ 'password': 'nc_pass', 'url': 'nc_url', 'username': 'nc_user', + 'verify_ssl': True, }) # --- # name: test_user_create_entry @@ -11,5 +12,6 @@ 'password': 'nc_pass', 'url': 'nc_url', 'username': 'nc_user', + 'verify_ssl': True, }) # --- diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py index 118d8fef0d..582ad3e77a 100644 --- a/tests/components/nextcloud/test_config_flow.py +++ b/tests/components/nextcloud/test_config_flow.py @@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.nextcloud import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -15,7 +15,12 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -VALID_CONFIG = {CONF_URL: "nc_url", CONF_USERNAME: "nc_user", CONF_PASSWORD: "nc_pass"} +VALID_CONFIG = { + CONF_URL: "nc_url", + CONF_USERNAME: "nc_user", + CONF_PASSWORD: "nc_pass", + CONF_VERIFY_SSL: True, +} async def test_user_create_entry( From 4877cf8d5daf19eb1dc975194c077a9611cc47cd Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 29 Mar 2023 14:30:30 -0400 Subject: [PATCH 0310/1165] Bump zwave-js-server-python to 0.47.1 (#90464) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 0ad934103d..5fb7726577 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index ea4a5c918a..39dad08e2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2728,7 +2728,7 @@ zigpy==0.53.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.0 +zwave-js-server-python==0.47.1 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daa52bc318..bfa3149095 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1953,7 +1953,7 @@ zigpy-znp==0.9.3 zigpy==0.53.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.0 +zwave-js-server-python==0.47.1 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 From a478e278fdbedec0130cb65760512d3a43439643 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Mar 2023 21:04:04 +0200 Subject: [PATCH 0311/1165] Update frontend to 20230329.0 (#90461) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2c13e81ee3..8c3fb8c143 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==20230309.1"] + "requirements": ["home-assistant-frontend==20230329.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7a1ae9b5c4..0ed98c78e1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230309.1 +home-assistant-frontend==20230329.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 39dad08e2b..d51947b81c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230309.1 +home-assistant-frontend==20230329.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfa3149095..514e653d34 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230309.1 +home-assistant-frontend==20230329.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 28d045cf75f85526c2d0a1cb64085e7e003406aa Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Wed, 29 Mar 2023 21:05:20 +0200 Subject: [PATCH 0312/1165] Allow resetting filters for waze_travel_time (#88253) * Allow resetting filters by using vol.Maybe * Fix return types * Use suggested values * Apply feedback * Apply nitpick --- .../waze_travel_time/config_flow.py | 52 ++++++------------- .../components/waze_travel_time/sensor.py | 19 ++++--- 2 files changed, 27 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index b26732e4cb..b885da3f37 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -31,6 +31,19 @@ from .const import ( ) from .helpers import is_valid_config_entry +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_INCL_FILTER, default=""): cv.string, + vol.Optional(CONF_EXCL_FILTER, default=""): cv.string, + vol.Optional(CONF_REALTIME): cv.boolean, + vol.Optional(CONF_VEHICLE_TYPE): vol.In(VEHICLE_TYPES), + vol.Optional(CONF_UNITS): vol.In(UNITS), + vol.Optional(CONF_AVOID_TOLL_ROADS): cv.boolean, + vol.Optional(CONF_AVOID_SUBSCRIPTION_ROADS): cv.boolean, + vol.Optional(CONF_AVOID_FERRIES): cv.boolean, + } +) + def default_options(hass: HomeAssistant) -> dict[str, str | bool]: """Get the default options.""" @@ -57,43 +70,8 @@ class WazeOptionsFlow(config_entries.OptionsFlow): return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_INCL_FILTER, - default=self.config_entry.options.get(CONF_INCL_FILTER, ""), - ): cv.string, - vol.Optional( - CONF_EXCL_FILTER, - default=self.config_entry.options.get(CONF_EXCL_FILTER, ""), - ): cv.string, - vol.Optional( - CONF_REALTIME, - default=self.config_entry.options[CONF_REALTIME], - ): cv.boolean, - vol.Optional( - CONF_VEHICLE_TYPE, - default=self.config_entry.options[CONF_VEHICLE_TYPE], - ): vol.In(VEHICLE_TYPES), - vol.Optional( - CONF_UNITS, - default=self.config_entry.options[CONF_UNITS], - ): vol.In(UNITS), - vol.Optional( - CONF_AVOID_TOLL_ROADS, - default=self.config_entry.options[CONF_AVOID_TOLL_ROADS], - ): cv.boolean, - vol.Optional( - CONF_AVOID_SUBSCRIPTION_ROADS, - default=self.config_entry.options[ - CONF_AVOID_SUBSCRIPTION_ROADS - ], - ): cv.boolean, - vol.Optional( - CONF_AVOID_FERRIES, - default=self.config_entry.options[CONF_AVOID_FERRIES], - ): cv.boolean, - } + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, self.config_entry.options ), ) diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index ecbf3e9e12..cf709805f6 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -60,8 +60,6 @@ async def async_setup_entry( name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) data = WazeTravelTimeData( - None, - None, region, config_entry, ) @@ -85,7 +83,14 @@ class WazeTravelTime(SensorEntity): configuration_url="https://www.waze.com", ) - def __init__(self, unique_id, name, origin, destination, waze_data): + def __init__( + self, + unique_id: str, + name: str, + origin: str, + destination: str, + waze_data: WazeTravelTimeData, + ) -> None: """Initialize the Waze travel time sensor.""" self._attr_unique_id = unique_id self._waze_data = waze_data @@ -126,7 +131,7 @@ class WazeTravelTime(SensorEntity): "destination": self._waze_data.destination, } - async def first_update(self, _=None): + async def first_update(self, _=None) -> None: """Run first update and write state.""" await self.hass.async_add_executor_job(self.update) self.async_write_ha_state() @@ -142,12 +147,12 @@ class WazeTravelTime(SensorEntity): class WazeTravelTimeData: """WazeTravelTime Data object.""" - def __init__(self, origin, destination, region, config_entry): + def __init__(self, region: str, config_entry: ConfigEntry) -> None: """Set up WazeRouteCalculator.""" - self.origin = origin - self.destination = destination self.region = region self.config_entry = config_entry + self.origin: str | None = None + self.destination: str | None = None self.duration = None self.distance = None self.route = None From cf0550f5c279eba18c55ce5289ffd75e23e994f9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Mar 2023 21:46:08 +0200 Subject: [PATCH 0313/1165] Add re-auth flow to nextcloud (#90472) --- .../components/nextcloud/__init__.py | 15 +- .../components/nextcloud/config_flow.py | 64 ++++++++- .../components/nextcloud/strings.json | 13 +- .../nextcloud/snapshots/test_config_flow.ambr | 8 ++ .../components/nextcloud/test_config_flow.py | 133 +++++++++++++++++- 5 files changed, 221 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 60489b3e30..65829f713e 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -1,7 +1,12 @@ """The Nextcloud integration.""" import logging -from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError +from nextcloudmonitor import ( + NextcloudMonitor, + NextcloudMonitorAuthorizationError, + NextcloudMonitorConnectionError, + NextcloudMonitorRequestError, +) import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -14,6 +19,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType @@ -82,9 +88,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: ncm = await hass.async_add_executor_job(_connect_nc) - except NextcloudMonitorError: - _LOGGER.error("Nextcloud setup failed - Check configuration") - return False + except NextcloudMonitorAuthorizationError as ex: + raise ConfigEntryAuthFailed from ex + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError) as ex: + raise ConfigEntryNotReady from ex coordinator = NextcloudDataUpdateCoordinator( hass, diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index f22d0a01a5..c5019603c0 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -1,13 +1,20 @@ """Config flow to configure the Nextcloud integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any -from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError +from nextcloudmonitor import ( + NextcloudMonitor, + NextcloudMonitorAuthorizationError, + NextcloudMonitorConnectionError, + NextcloudMonitorError, + NextcloudMonitorRequestError, +) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.data_entry_flow import FlowResult @@ -21,6 +28,13 @@ DATA_SCHEMA_USER = vol.Schema( vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, } ) +DATA_SCHEMA_REAUTH = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + _LOGGER = logging.getLogger(__name__) @@ -29,6 +43,8 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _entry: ConfigEntry | None = None + def _try_connect_nc(self, user_input: dict) -> NextcloudMonitor: """Try to connect to nextcloud server.""" return NextcloudMonitor( @@ -67,7 +83,9 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)}) try: await self.hass.async_add_executor_job(self._try_connect_nc, user_input) - except NextcloudMonitorError: + except NextcloudMonitorAuthorizationError: + errors["base"] = "invalid_auth" + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError): errors["base"] = "connection_error" else: return self.async_create_entry( @@ -79,3 +97,43 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): 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]) -> FlowResult: + """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 + ) -> FlowResult: + """Handle reauthorization flow.""" + errors = {} + assert self._entry is not None + + if user_input is not None: + try: + await self.hass.async_add_executor_job( + self._try_connect_nc, {**self._entry.data, **user_input} + ) + except NextcloudMonitorAuthorizationError: + errors["base"] = "invalid_auth" + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError): + errors["base"] = "connection_error" + else: + self.hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, **user_input}, + ) + await self.hass.config_entries.async_reload(self._entry.entry_id) + return self.async_abort(reason="reauth_successful") + + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA_REAUTH, + {CONF_USERNAME: self._entry.data[CONF_USERNAME], **(user_input or {})}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + description_placeholders={"url": self._entry.data[CONF_URL]}, + errors=errors, + ) diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index dc0175ea8e..782865032a 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -10,14 +10,23 @@ "password": "[%key:common::config_flow::data::password%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } + }, + "reauth_confirm": { + "description": "Update your login information for {url}.", + "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%]", - "connection_error_during_import": "Connection error occured during yaml configuration import" + "connection_error_during_import": "Connection error occured during yaml configuration import", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { - "connection_error": "[%key:common::config_flow::error::cannot_connect%]" + "connection_error": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } }, "issues": { diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr index caa9528507..3334478ba2 100644 --- a/tests/components/nextcloud/snapshots/test_config_flow.ambr +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -7,6 +7,14 @@ 'verify_ssl': True, }) # --- +# name: test_reauth + dict({ + 'password': 'other_password', + 'url': 'nc_url', + 'username': 'other_user', + 'verify_ssl': True, + }) +# --- # name: test_user_create_entry dict({ 'password': 'nc_pass', diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py index 582ad3e77a..ba465c5f8a 100644 --- a/tests/components/nextcloud/test_config_flow.py +++ b/tests/components/nextcloud/test_config_flow.py @@ -1,12 +1,17 @@ """Tests for the Nextcloud config flow.""" from unittest.mock import Mock, patch -from nextcloudmonitor import NextcloudMonitorError +from nextcloudmonitor import ( + NextcloudMonitorAuthorizationError, + NextcloudMonitorConnectionError, + NextcloudMonitorError, + NextcloudMonitorRequestError, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.nextcloud import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -27,6 +32,7 @@ async def test_user_create_entry( hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion ) -> None: """Test that the user step works.""" + # start user flow result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -34,9 +40,24 @@ async def test_user_create_entry( assert result["step_id"] == "user" assert result["errors"] == {} + # test NextcloudMonitorAuthorizationError with patch( "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", - side_effect=NextcloudMonitorError, + side_effect=NextcloudMonitorAuthorizationError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # test NextcloudMonitorConnectionError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -47,6 +68,21 @@ async def test_user_create_entry( assert result["step_id"] == "user" assert result["errors"] == {"base": "connection_error"} + # test NextcloudMonitorRequestError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorRequestError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "connection_error"} + + # test success with patch( "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", return_value=mock_nextcloud_monitor, @@ -154,3 +190,94 @@ async def test_import_connection_error(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] == FlowResultType.ABORT assert result["reason"] == "connection_error_during_import" + + +async def test_reauth( + hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion +) -> None: + """Test that the re-auth flow works.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="nc_url", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + # start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # test NextcloudMonitorAuthorizationError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorAuthorizationError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # test NextcloudMonitorConnectionError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorConnectionError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "connection_error"} + + # test NextcloudMonitorRequestError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorRequestError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "connection_error"} + + # test success + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == snapshot From 5bc9545b81d016c002c7672daec237a580bce74f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Mar 2023 21:58:25 +0200 Subject: [PATCH 0314/1165] Rename custom_jinja to custom_templates (#90473) Co-authored-by: Franck Nijhof --- homeassistant/bootstrap.py | 2 +- .../components/homeassistant/__init__.py | 14 +++++++------- .../components/homeassistant/services.yaml | 4 ++-- homeassistant/helpers/template.py | 12 ++++++------ tests/components/homeassistant/test_init.py | 16 ++++++++-------- tests/helpers/test_template.py | 6 +++--- .../inner/inner_test.jinja | 0 .../test.jinja | 0 8 files changed, 27 insertions(+), 27 deletions(-) rename tests/testing_config/{custom_jinja => custom_templates}/inner/inner_test.jinja (100%) rename tests/testing_config/{custom_jinja => custom_templates}/test.jinja (100%) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index eb3aa3a223..445ff35793 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -245,7 +245,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: entity_registry.async_load(hass), issue_registry.async_load(hass), hass.async_add_executor_job(_cache_uname_processor), - template.async_load_custom_jinja(hass), + template.async_load_custom_templates(hass), ) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 4b033fd711..91dd742e80 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -30,7 +30,7 @@ from homeassistant.helpers.service import ( async_extract_referenced_entity_ids, async_register_admin_service, ) -from homeassistant.helpers.template import async_load_custom_jinja +from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType ATTR_ENTRY_ID = "entry_id" @@ -39,7 +39,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" -SERVICE_RELOAD_CUSTOM_JINJA = "reload_custom_jinja" +SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates" SERVICE_CHECK_CONFIG = "check_config" SERVICE_UPDATE_ENTITY = "update_entity" SERVICE_SET_LOCATION = "set_location" @@ -260,12 +260,12 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no vol.Schema({ATTR_LATITUDE: cv.latitude, ATTR_LONGITUDE: cv.longitude}), ) - async def async_handle_reload_jinja(call: ha.ServiceCall) -> None: + async def async_handle_reload_templates(call: ha.ServiceCall) -> None: """Service handler to reload custom Jinja.""" - await async_load_custom_jinja(hass) + await async_load_custom_templates(hass) async_register_admin_service( - hass, ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA, async_handle_reload_jinja + hass, ha.DOMAIN, SERVICE_RELOAD_CUSTOM_TEMPLATES, async_handle_reload_templates ) async def async_handle_reload_config_entry(call: ha.ServiceCall) -> None: @@ -300,7 +300,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no Additionally, it also calls the `homeasssitant.reload_core_config` service, as that reloads the core YAML configuration, the `frontend.reload_themes` service that reloads the themes, and the - `homeassistant.reload_custom_jinja` service that reloads any custom + `homeassistant.reload_custom_templates` service that reloads any custom jinja into memory. We only do so, if there are no configuration errors. @@ -330,7 +330,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no for domain, service in ( (ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG), ("frontend", "reload_themes"), - (ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA), + (ha.DOMAIN, SERVICE_RELOAD_CUSTOM_TEMPLATES), ) ] diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 20f23402a7..2fe27769c3 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -59,10 +59,10 @@ update_entity: target: entity: {} -reload_custom_jinja: +reload_custom_templates: name: Reload custom Jinja2 templates description: >- - Reload Jinja2 templates found in the custom_jinja folder in your config. + Reload Jinja2 templates found in the custom_templates folder in your config. New values will be applied on the next render of the template. reload_config_entry: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index d3aa7c81ff..481a59cee8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -124,7 +124,7 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 -MAX_CUSTOM_JINJA_SIZE = 5 * 1024 * 1024 +MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 @bind_hass @@ -2084,18 +2084,18 @@ class LoggingUndefined(jinja2.Undefined): return super().__bool__() -async def async_load_custom_jinja(hass: HomeAssistant) -> None: +async def async_load_custom_templates(hass: HomeAssistant) -> None: """Load all custom jinja files under 5MiB into memory.""" - return await hass.async_add_executor_job(_load_custom_jinja, hass) + return await hass.async_add_executor_job(_load_custom_templates, hass) -def _load_custom_jinja(hass: HomeAssistant) -> None: +def _load_custom_templates(hass: HomeAssistant) -> None: result = {} - jinja_path = hass.config.path("custom_jinja") + jinja_path = hass.config.path("custom_templates") all_files = [ item for item in pathlib.Path(jinja_path).rglob("*.jinja") - if item.is_file() and item.stat().st_size <= MAX_CUSTOM_JINJA_SIZE + if item.is_file() and item.stat().st_size <= MAX_CUSTOM_TEMPLATE_SIZE ] for file in all_files: content = file.read_text() diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 4a04241696..652fc4a1fd 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -14,7 +14,7 @@ from homeassistant.components.homeassistant import ( SERVICE_CHECK_CONFIG, SERVICE_RELOAD_ALL, SERVICE_RELOAD_CORE_CONFIG, - SERVICE_RELOAD_CUSTOM_JINJA, + SERVICE_RELOAD_CUSTOM_TEMPLATES, SERVICE_SET_LOCATION, ) from homeassistant.const import ( @@ -576,19 +576,19 @@ async def test_save_persistent_states(hass: HomeAssistant) -> None: assert mock_save.called -async def test_reload_custom_jinja(hass: HomeAssistant) -> None: - """Test we can call reload_custom_jinja.""" +async def test_reload_custom_templates(hass: HomeAssistant) -> None: + """Test we can call reload_custom_templates.""" await async_setup_component(hass, "homeassistant", {}) with patch( - "homeassistant.components.homeassistant.async_load_custom_jinja", + "homeassistant.components.homeassistant.async_load_custom_templates", return_value=None, - ) as mock_load_custom_jinja: + ) as mock_load_custom_templates: await hass.services.async_call( "homeassistant", - SERVICE_RELOAD_CUSTOM_JINJA, + SERVICE_RELOAD_CUSTOM_TEMPLATES, blocking=True, ) - assert mock_load_custom_jinja.called + assert mock_load_custom_templates.called async def test_reload_all( @@ -602,7 +602,7 @@ async def test_reload_all( notify = async_mock_service(hass, "notify", "reload") core_config = async_mock_service(hass, "homeassistant", "reload_core_config") themes = async_mock_service(hass, "frontend", "reload_themes") - jinja = async_mock_service(hass, "homeassistant", "reload_custom_jinja") + jinja = async_mock_service(hass, "homeassistant", "reload_custom_templates") with patch( "homeassistant.config.async_check_ha_config_file", diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 45237a5cbf..b381775f1e 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -245,8 +245,8 @@ def test_iterating_domain_states(hass: HomeAssistant) -> None: async def test_import(hass: HomeAssistant) -> None: - """Test that imports work from the config/custom_jinja folder.""" - await template.async_load_custom_jinja(hass) + """Test that imports work from the config/custom_templates folder.""" + await template.async_load_custom_templates(hass) assert "test.jinja" in template._get_hass_loader(hass).sources assert "inner/inner_test.jinja" in template._get_hass_loader(hass).sources assert ( @@ -283,7 +283,7 @@ async def test_import(hass: HomeAssistant) -> None: async def test_import_change(hass: HomeAssistant) -> None: """Test that a change in HassLoader results in updated imports.""" - await template.async_load_custom_jinja(hass) + await template.async_load_custom_templates(hass) to_test = template.Template( """ {% import 'test.jinja' as t %} diff --git a/tests/testing_config/custom_jinja/inner/inner_test.jinja b/tests/testing_config/custom_templates/inner/inner_test.jinja similarity index 100% rename from tests/testing_config/custom_jinja/inner/inner_test.jinja rename to tests/testing_config/custom_templates/inner/inner_test.jinja diff --git a/tests/testing_config/custom_jinja/test.jinja b/tests/testing_config/custom_templates/test.jinja similarity index 100% rename from tests/testing_config/custom_jinja/test.jinja rename to tests/testing_config/custom_templates/test.jinja From fc67a147ce15b59c16b07cf419e7eaf5344f7710 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Mar 2023 22:01:31 +0200 Subject: [PATCH 0315/1165] Bumped version to 2023.4.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1559560f11..289f536089 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 577ba18140..e7e82d2ed5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0.dev0" +version = "2023.4.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 7c778847e7dbc052d35f1d4758e8829d73be1e28 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Wed, 29 Mar 2023 16:13:41 -0400 Subject: [PATCH 0316/1165] Add config flow to qBittorrent (#82560) * qbittorrent: implement config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: add English translations Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: create sensors with config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: set unique_id and icon Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: add tests for config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: detect duplicate config entries Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: import YAML config Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: update coveragerc Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: delete translations file * create `deprecated_yaml` issue in `setup_platform` * move qbittorrent test fixtures to conftest.py * improve code quality & remove wrong unique_id * keep PLATFORM_SCHEMA until YAML support is removed * remove CONF_NAME in config entry, fix setup_entry * improve test suite * clean up QBittorrentSensor class * improve user flow tests * explicit result assertion & minor tweaks in tests Co-authored-by: epenet * implement entry unloading Co-authored-by: epenet * add type hints * tweak config_flow data handling --------- Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Co-authored-by: epenet --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/qbittorrent/__init__.py | 53 +++++++ .../components/qbittorrent/config_flow.py | 76 ++++++++++ homeassistant/components/qbittorrent/const.py | 4 + .../components/qbittorrent/helpers.py | 11 ++ .../components/qbittorrent/manifest.json | 1 + .../components/qbittorrent/sensor.py | 56 +++++--- .../components/qbittorrent/strings.json | 27 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/qbittorrent/__init__.py | 1 + tests/components/qbittorrent/conftest.py | 25 ++++ .../qbittorrent/test_config_flow.py | 136 ++++++++++++++++++ 15 files changed, 375 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/qbittorrent/config_flow.py create mode 100644 homeassistant/components/qbittorrent/helpers.py create mode 100644 homeassistant/components/qbittorrent/strings.json create mode 100644 tests/components/qbittorrent/__init__.py create mode 100644 tests/components/qbittorrent/conftest.py create mode 100644 tests/components/qbittorrent/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 4b831fc3d3..2c06c7d0bb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -938,6 +938,7 @@ omit = homeassistant/components/pushover/notify.py homeassistant/components/pushsafer/notify.py homeassistant/components/pyload/sensor.py + homeassistant/components/qbittorrent/__init__.py homeassistant/components/qbittorrent/sensor.py homeassistant/components/qnap/sensor.py homeassistant/components/qrcode/image_processing.py diff --git a/CODEOWNERS b/CODEOWNERS index 1acd5f6c9f..509f3e5f30 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -933,6 +933,7 @@ build.json @home-assistant/supervisor /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue /homeassistant/components/qbittorrent/ @geoffreylagaisse +/tests/components/qbittorrent/ @geoffreylagaisse /homeassistant/components/qingping/ @bdraco @skgsergio /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index a5274f7a5a..5154ae155e 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -1 +1,54 @@ """The qbittorrent component.""" +import logging + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .helpers import setup_client + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up qBittorrent from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + try: + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + setup_client, + entry.data[CONF_URL], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_VERIFY_SSL], + ) + except LoginRequired as err: + _LOGGER.error("Invalid credentials") + raise ConfigEntryNotReady from err + except RequestException as err: + _LOGGER.error("Failed to connect") + raise ConfigEntryNotReady from err + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload qBittorrent config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + return unload_ok diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py new file mode 100644 index 0000000000..54c47c5389 --- /dev/null +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for qBittorrent.""" +from __future__ import annotations + +import logging +from typing import Any + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN +from .helpers import setup_client + +_LOGGER = logging.getLogger(__name__) + +USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for the qBittorrent integration.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a user-initiated config flow.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + try: + await self.hass.async_add_executor_job( + setup_client, + user_input[CONF_URL], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_VERIFY_SSL], + ) + except LoginRequired: + errors = {"base": "invalid_auth"} + except RequestException: + errors = {"base": "cannot_connect"} + else: + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) + + schema = self.add_suggested_values_to_schema(USER_DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._async_abort_entries_match({CONF_URL: config[CONF_URL]}) + return self.async_create_entry( + title=config.get(CONF_NAME, DEFAULT_NAME), + data={ + CONF_URL: config[CONF_URL], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_VERIFY_SSL: True, + }, + ) diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py index 5f9ad42f7f..0a79c67f40 100644 --- a/homeassistant/components/qbittorrent/const.py +++ b/homeassistant/components/qbittorrent/const.py @@ -1,3 +1,7 @@ """Constants for qBittorrent.""" +from typing import Final + +DOMAIN: Final = "qbittorrent" DEFAULT_NAME = "qBittorrent" +DEFAULT_URL = "http://127.0.0.1:8080" diff --git a/homeassistant/components/qbittorrent/helpers.py b/homeassistant/components/qbittorrent/helpers.py new file mode 100644 index 0000000000..7f7833e912 --- /dev/null +++ b/homeassistant/components/qbittorrent/helpers.py @@ -0,0 +1,11 @@ +"""Helper functions for qBittorrent.""" +from qbittorrent.client import Client + + +def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client: + """Create a qBittorrent client.""" + client = Client(url, verify=verify_ssl) + client.login(username, password) + # Get an arbitrary attribute to test if connection succeeds + client.get_alternative_speed_status() + return client diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 47090ab8b9..c56bb8102b 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -2,6 +2,7 @@ "domain": "qbittorrent", "name": "qBittorrent", "codeowners": ["@geoffreylagaisse"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qbittorrent", "integration_type": "service", "iot_class": "local_polling", diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index cafb8d8b21..6b758daab0 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -23,12 +24,12 @@ from homeassistant.const import ( UnitOfDataRate, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DEFAULT_NAME +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -69,31 +70,41 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the qBittorrent sensors.""" + """Set up the qBittorrent platform.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) - try: - client = Client(config[CONF_URL]) - client.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - except LoginRequired: - _LOGGER.error("Invalid authentication") - return - except RequestException as err: - _LOGGER.error("Connection failed") - raise PlatformNotReady from err - - name = config.get(CONF_NAME) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entites: AddEntitiesCallback, +) -> None: + """Set up qBittorrent sensor entries.""" + client: Client = hass.data[DOMAIN][config_entry.entry_id] entities = [ - QBittorrentSensor(description, client, name) for description in SENSOR_TYPES + QBittorrentSensor(description, client, config_entry) + for description in SENSOR_TYPES ] - - add_entities(entities, True) + async_add_entites(entities, True) def format_speed(speed): @@ -108,14 +119,15 @@ class QBittorrentSensor(SensorEntity): def __init__( self, description: SensorEntityDescription, - qbittorrent_client, - client_name, + qbittorrent_client: Client, + config_entry: ConfigEntry, ) -> None: """Initialize the qBittorrent sensor.""" self.entity_description = description self.client = qbittorrent_client - self._attr_name = f"{client_name} {description.name}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" + self._attr_name = f"{config_entry.title} {description.name}" self._attr_available = False def update(self) -> None: diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json new file mode 100644 index 0000000000..24d1885a91 --- /dev/null +++ b/homeassistant/components/qbittorrent/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The qBittorrent YAML configuration is being removed", + "description": "Configuring qBittorrent using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the qBittorrent YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 37480904f9..240e30ec86 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -339,6 +339,7 @@ FLOWS = { "pushover", "pvoutput", "pvpc_hourly_pricing", + "qbittorrent", "qingping", "qnap_qsw", "rachio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3e89f9d12d..bc0e9e1c33 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4316,7 +4316,7 @@ "qbittorrent": { "name": "qBittorrent", "integration_type": "service", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "qingping": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 514e653d34..5b2f1be04c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1507,6 +1507,9 @@ python-otbr-api==1.0.9 # homeassistant.components.picnic python-picnic-api==1.1.0 +# homeassistant.components.qbittorrent +python-qbittorrent==0.4.2 + # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/qbittorrent/__init__.py b/tests/components/qbittorrent/__init__.py new file mode 100644 index 0000000000..2be020668c --- /dev/null +++ b/tests/components/qbittorrent/__init__.py @@ -0,0 +1 @@ +"""Tests for the qBittorrent integration.""" diff --git a/tests/components/qbittorrent/conftest.py b/tests/components/qbittorrent/conftest.py new file mode 100644 index 0000000000..448f68db81 --- /dev/null +++ b/tests/components/qbittorrent/conftest.py @@ -0,0 +1,25 @@ +"""Fixtures for testing qBittorrent component.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +import requests_mock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock qbittorrent entry setup.""" + with patch( + "homeassistant.components.qbittorrent.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_api() -> Generator[requests_mock.Mocker, None, None]: + """Mock the qbittorrent API.""" + with requests_mock.Mocker() as mocker: + mocker.get("http://localhost:8080/api/v2/app/preferences", status_code=403) + mocker.get("http://localhost:8080/api/v2/transfer/speedLimitsMode") + mocker.post("http://localhost:8080/api/v2/auth/login", text="Ok.") + yield mocker diff --git a/tests/components/qbittorrent/test_config_flow.py b/tests/components/qbittorrent/test_config_flow.py new file mode 100644 index 0000000000..b7244ccef8 --- /dev/null +++ b/tests/components/qbittorrent/test_config_flow.py @@ -0,0 +1,136 @@ +"""Test the qBittorrent config flow.""" +import pytest +from requests.exceptions import RequestException +import requests_mock + +from homeassistant.components.qbittorrent.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_PASSWORD, + CONF_SOURCE, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +USER_INPUT = { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, +} + +YAML_IMPORT = { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} + + +async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> None: + """Test the user flow.""" + # Open flow as USER with no input + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Test flow with connection failure, fail with cannot_connect + with requests_mock.Mocker() as mock: + mock.get( + f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", + exc=RequestException, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # Test flow with wrong creds, fail with invalid_auth + with requests_mock.Mocker() as mock: + mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/transfer/speedLimitsMode") + mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", status_code=403) + mock.post( + f"{USER_INPUT[CONF_URL]}/api/v2/auth/login", + text="Wrong username/password", + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # Test flow with proper input, succeed + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, + } + + +async def test_flow_user_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry.add_to_hass(hass) + + # Open flow as USER with no input + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Test flow with duplicate config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_import(hass: HomeAssistant) -> None: + """Test import step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=YAML_IMPORT, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, + } + + +async def test_flow_import_already_configured(hass: HomeAssistant) -> None: + """Test import step already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=YAML_IMPORT, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" From 7010447b04be53d4c9f1cc696cd9210f87611d90 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Mar 2023 22:46:32 +0200 Subject: [PATCH 0317/1165] Bump version to 2023.5.0dev0 (#90477) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e4fd319e71..8464b1a299 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.4 + HA_SHORT_VERSION: 2023.5 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 1559560f11..23b4a9a132 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 4 +MINOR_VERSION: Final = 5 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 577ba18140..d409ef188d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0.dev0" +version = "2023.5.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 43a7247ddeb9545d03cabd1ce6b2f4fc46c59663 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Wed, 29 Mar 2023 18:04:37 -0300 Subject: [PATCH 0318/1165] Move ProxmoxEntity to entity.py (#90480) * Move ProxmoxEntity to entity.py * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/proxmoxve/__init__.py | 53 +------------------ .../components/proxmoxve/binary_sensor.py | 3 +- homeassistant/components/proxmoxve/entity.py | 39 ++++++++++++++ 3 files changed, 42 insertions(+), 53 deletions(-) create mode 100644 homeassistant/components/proxmoxve/entity.py diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 2764f22b08..b61f0ca4df 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -22,10 +22,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( _LOGGER, @@ -252,54 +249,6 @@ def call_api_container_vm( return status -class ProxmoxEntity(CoordinatorEntity): - """Represents any entity created for the Proxmox VE platform.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - unique_id: str, - name: str, - icon: str, - host_name: str, - node_name: str, - vm_id: int | None = None, - ) -> None: - """Initialize the Proxmox entity.""" - super().__init__(coordinator) - - self.coordinator = coordinator - self._unique_id = unique_id - self._name = name - self._host_name = host_name - self._icon = icon - self._available = True - self._node_name = node_name - self._vm_id = vm_id - - self._state = None - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.coordinator.last_update_success and self._available - - class ProxmoxClient: """A wrapper for the proxmoxer ProxmoxAPI client.""" diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 828c819114..ea02e547e9 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -10,7 +10,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import COORDINATORS, DOMAIN, PROXMOX_CLIENTS, ProxmoxEntity +from .const import COORDINATORS, DOMAIN, PROXMOX_CLIENTS +from .entity import ProxmoxEntity async def async_setup_platform( diff --git a/homeassistant/components/proxmoxve/entity.py b/homeassistant/components/proxmoxve/entity.py new file mode 100644 index 0000000000..5dfd264df2 --- /dev/null +++ b/homeassistant/components/proxmoxve/entity.py @@ -0,0 +1,39 @@ +"""Proxmox parent entity class.""" + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + + +class ProxmoxEntity(CoordinatorEntity): + """Represents any entity created for the Proxmox VE platform.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int | None = None, + ) -> None: + """Initialize the Proxmox entity.""" + super().__init__(coordinator) + + self.coordinator = coordinator + self._attr_unique_id = unique_id + self._attr_name = name + self._host_name = host_name + self._attr_icon = icon + self._available = True + self._node_name = node_name + self._vm_id = vm_id + + self._state = None + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.last_update_success and self._available From d0a492644d9773124692de286f34922b454edf53 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:24:26 -0400 Subject: [PATCH 0319/1165] Correctly load ZHA settings from API when integration is not running (#90476) Correctly load settings from the zigpy database when ZHA is not running --- homeassistant/components/zha/api.py | 23 ++++++++--------------- tests/components/zha/test_api.py | 5 ++++- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index d34dd2338e..652f19d24b 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -18,8 +18,6 @@ from .core.const import ( from .core.gateway import ZHAGateway if TYPE_CHECKING: - from zigpy.application import ControllerApplication - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -49,21 +47,17 @@ def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: return entries[0] -def _wrap_network_settings(app: ControllerApplication) -> NetworkBackup: - """Wrap the ZHA network settings into a `NetworkBackup`.""" +def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: + """Get the network settings for the currently active ZHA network.""" + zha_gateway: ZHAGateway = _get_gateway(hass) + app = zha_gateway.application_controller + return NetworkBackup( node_info=app.state.node_info, network_info=app.state.network_info, ) -def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: - """Get the network settings for the currently active ZHA network.""" - zha_gateway: ZHAGateway = _get_gateway(hass) - - return _wrap_network_settings(zha_gateway.application_controller) - - async def async_get_last_network_settings( hass: HomeAssistant, config_entry: ConfigEntry | None = None ) -> NetworkBackup | None: @@ -79,13 +73,12 @@ async def async_get_last_network_settings( try: await app._load_db() # pylint: disable=protected-access - settings = _wrap_network_settings(app) + settings = max(app.backups, key=lambda b: b.backup_time) + except ValueError: + settings = None finally: await app.shutdown() - if settings.network_info.channel == 0: - return None - return settings diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index c607909980..59daf2179b 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +import zigpy.backups import zigpy.state from homeassistant.components import zha @@ -36,7 +37,9 @@ async def test_async_get_network_settings_inactive( gateway = api._get_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) - zigpy_app_controller.state.network_info.channel = 20 + backup = zigpy.backups.NetworkBackup() + backup.network_info.channel = 20 + zigpy_app_controller.backups.backups.append(backup) with patch( "bellows.zigbee.application.ControllerApplication.__new__", From 3bebd4318e99e94ae82a0d941b458e4014a86b38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:24:47 -1000 Subject: [PATCH 0320/1165] Bump yalexs-ble to 2.1.14 (#90474) changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.13...v2.1.14 reduces ble traffic (fixes a bug were we were checking when we did not need to be) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 07ecc2a1be..84b5ae7e20 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.13"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.14"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 7c45f309e6..f1ec6ba14c 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.13"] + "requirements": ["yalexs-ble==2.1.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index d51947b81c..bd80ded757 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b2f1be04c..3519962af1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1914,7 +1914,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 From 706e6597d8189f02d17cfb87b0ef0c4ebbc6cb5f Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 29 Mar 2023 23:25:33 +0200 Subject: [PATCH 0321/1165] Add entity name translations for devolo Home Network (#90471) --- .../devolo_home_network/binary_sensor.py | 1 - .../components/devolo_home_network/entity.py | 1 + .../components/devolo_home_network/sensor.py | 3 --- .../devolo_home_network/strings.json | 26 +++++++++++++++++++ .../components/devolo_home_network/switch.py | 2 -- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index e927ea9333..809dc9086b 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -53,7 +53,6 @@ SENSOR_TYPES: dict[str, DevoloBinarySensorEntityDescription] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:router-network", - name="Connected to router", value_func=_is_connected_to_router, ), } diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a26d8dce8f..8b665d7bf0 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -57,4 +57,5 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): name=entry.title, sw_version=device.firmware_version, ) + self._attr_translation_key = self.entity_description.key self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 2c2637c2f8..aeeab2ce89 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -54,7 +54,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lan", - name="Connected PLC devices", value_func=lambda data: len( {device.mac_address_from for device in data.data_rates} ), @@ -62,7 +61,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[list[ConnectedStationInfo]]( key=CONNECTED_WIFI_CLIENTS, icon="mdi:wifi", - name="Connected Wifi clients", state_class=SensorStateClass.MEASUREMENT, value_func=len, ), @@ -71,7 +69,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:wifi-marker", - name="Neighboring Wifi networks", value_func=len, ), } diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 6c320710a1..3472886cd5 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -27,5 +27,31 @@ "home_control": "The devolo Home Control Central Unit does not work with this integration.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "connected_to_router": { + "name": "Connected to router" + } + }, + "sensor": { + "connected_plc_devices": { + "name": "Connected PLC devices" + }, + "connected_wifi_clients": { + "name": "Connected Wifi clients" + }, + "neighboring_wifi_networks": { + "name": "Neighboring Wifi networks" + } + }, + "switch": { + "switch_guest_wifi": { + "name": "Enable guest Wifi" + }, + "switch_leds": { + "name": "Enable LEDs" + } + } } } diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index fa2447985d..6f387fdf05 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -42,7 +42,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { SWITCH_GUEST_WIFI: DevoloSwitchEntityDescription[WifiGuestAccessGet]( key=SWITCH_GUEST_WIFI, icon="mdi:wifi", - name="Enable guest Wifi", is_on_func=lambda data: data.enabled is True, turn_on_func=lambda device: device.device.async_set_wifi_guest_access(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_wifi_guest_access(False), # type: ignore[union-attr] @@ -51,7 +50,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { key=SWITCH_LEDS, entity_category=EntityCategory.CONFIG, icon="mdi:led-off", - name="Enable LEDs", is_on_func=bool, turn_on_func=lambda device: device.device.async_set_led_setting(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_led_setting(False), # type: ignore[union-attr] From 1023628821e8e68b3b16b42003e276c297c364fa Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Mar 2023 23:26:05 +0200 Subject: [PATCH 0322/1165] Bump reolink-aio to 0.5.8 (#90467) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 95b180fc16..79fc15c571 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.7"] + "requirements": ["reolink-aio==0.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index bd80ded757..cae40bd2c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3519962af1..b819133fc7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 From 4c21caa917fe8f77f869c895c5b068504a27a068 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:26:28 -1000 Subject: [PATCH 0323/1165] Fix filesize doing blocking I/O in the event loop (#90479) Fix filesize doing I/O in the event loop --- homeassistant/components/filesize/__init__.py | 19 +++++++------------ .../components/filesize/config_flow.py | 4 +++- homeassistant/core.py | 6 +++++- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 9e08615d4a..73f060e79b 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -11,24 +11,19 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import PLATFORMS -def check_path(path: pathlib.Path) -> bool: - """Check path.""" - return path.exists() and path.is_file() - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up from a config entry.""" - - path = entry.data[CONF_FILE_PATH] +def _check_path(hass: HomeAssistant, path: str) -> None: + """Check if path is valid and allowed.""" get_path = pathlib.Path(path) - - check_file = await hass.async_add_executor_job(check_path, get_path) - if not check_file: + if not get_path.exists() or not get_path.is_file(): raise ConfigEntryNotReady(f"Can not access file {path}") if not hass.config.is_allowed_path(path): raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed") + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH]) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/config_flow.py b/homeassistant/components/filesize/config_flow.py index 3f58e636b0..8633e6ec46 100644 --- a/homeassistant/components/filesize/config_flow.py +++ b/homeassistant/components/filesize/config_flow.py @@ -49,7 +49,9 @@ class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - full_path = validate_path(self.hass, user_input[CONF_FILE_PATH]) + full_path = await self.hass.async_add_executor_job( + validate_path, self.hass, user_input[CONF_FILE_PATH] + ) except NotValidError: errors["base"] = "not_valid" except NotAllowedError: diff --git a/homeassistant/core.py b/homeassistant/core.py index 900355d4a5..78ceb620e5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1950,7 +1950,11 @@ class Config: ) def is_allowed_path(self, path: str) -> bool: - """Check if the path is valid for access from outside.""" + """Check if the path is valid for access from outside. + + This function does blocking I/O and should not be called from the event loop. + Use hass.async_add_executor_job to schedule it on the executor. + """ assert path is not None thepath = pathlib.Path(path) From 93d1961aae130ce12c354c1b6c9ba6668540b699 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Wed, 29 Mar 2023 23:43:54 +0200 Subject: [PATCH 0324/1165] Use auth token in Ezviz (#54663) * Initial commit * Revert "Initial commit" This reverts commit 452027f1a3c1be186cedd4115cea6928917c9467. * Change ezviz to token auth * Bump API version. * Add fix for token expired. Fix options update and unload. * Fix tests (PLATFORM to PLATFORM_BY_TYPE) * Uses and stores token only, added reauth step when token expires. * Add tests MFA code exceptions. * Fix tests. * Remove redundant try/except blocks. * Rebase fixes. * Fix errors in reauth config flow * Implement recommendations * Fix typing error in config_flow * Fix tests after rebase, readd camera check on init * Change to platform setup * Cleanup init. * Test for MFA required under user form * Remove useless if block. * Fix formating after rebase * Fix formating. * No longer stored in the repository --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/ezviz/__init__.py | 137 +++++---- homeassistant/components/ezviz/camera.py | 14 +- homeassistant/components/ezviz/config_flow.py | 286 +++++++++++------- homeassistant/components/ezviz/const.py | 5 +- homeassistant/components/ezviz/coordinator.py | 20 +- homeassistant/components/ezviz/strings.json | 14 +- tests/components/ezviz/__init__.py | 26 +- tests/components/ezviz/conftest.py | 8 +- tests/components/ezviz/test_config_flow.py | 242 +++++++++++++-- 9 files changed, 535 insertions(+), 217 deletions(-) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index fbd49102f3..489ff97eb4 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -2,26 +2,26 @@ import logging from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_TYPE, - CONF_URL, - CONF_USERNAME, - Platform, -) +from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DATA_COORDINATOR, - DATA_UNDO_UPDATE_LISTENER, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -30,17 +30,22 @@ from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CAMERA, - Platform.SENSOR, - Platform.SWITCH, -] +PLATFORMS_BY_TYPE: dict[str, list] = { + ATTR_TYPE_CAMERA: [], + ATTR_TYPE_CLOUD: [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.SENSOR, + Platform.SWITCH, + ], +} async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up EZVIZ from a config entry.""" hass.data.setdefault(DOMAIN, {}) + sensor_type: str = entry.data[CONF_TYPE] + ezviz_client = None if not entry.options: options = { @@ -50,69 +55,71 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, options=options) - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - if hass.data.get(DOMAIN): - # Should only execute on addition of new camera entry. - # Fetch Entry id of main account and reload it. - for item in hass.config_entries.async_entries(): - if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - _LOGGER.info("Reload EZVIZ integration with new camera rtsp entry") - await hass.config_entries.async_reload(item.entry_id) + # Initialize EZVIZ cloud entities + if PLATFORMS_BY_TYPE[sensor_type]: + # Initiate reauth config flow if account token if not present. + if not entry.data.get(CONF_SESSION_ID): + raise ConfigEntryAuthFailed - return True - - try: - ezviz_client = await hass.async_add_executor_job( - _get_ezviz_client_instance, entry + ezviz_client = EzvizClient( + token={ + CONF_SESSION_ID: entry.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: entry.data.get(CONF_RFSESSION_ID), + "api_url": entry.data.get(CONF_URL), + }, + timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - except (InvalidURL, HTTPError, PyEzvizError) as error: - _LOGGER.error("Unable to connect to EZVIZ service: %s", str(error)) - raise ConfigEntryNotReady from error - coordinator = EzvizDataUpdateCoordinator( - hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + try: + await hass.async_add_executor_job(ezviz_client.login) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error + + except (InvalidURL, HTTPError, PyEzvizError) as error: + _LOGGER.error("Unable to connect to Ezviz service: %s", str(error)) + raise ConfigEntryNotReady from error + + coordinator = EzvizDataUpdateCoordinator( + hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. + # Cameras are accessed via local RTSP stream with unique credentials per camera. + # Separate camera entities allow for credential changes per camera. + if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]: + for item in hass.config_entries.async_entries(domain=DOMAIN): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + _LOGGER.info("Reload Ezviz main account with camera entry") + await hass.config_entries.async_reload(item.entry_id) + return True + + await hass.config_entries.async_forward_entry_setups( + entry, PLATFORMS_BY_TYPE[sensor_type] ) - await coordinator.async_refresh() - - if not coordinator.last_update_success: - raise ConfigEntryNotReady - - undo_listener = entry.add_update_listener(_async_update_listener) - - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, - } - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + sensor_type = entry.data[CONF_TYPE] - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - return True - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() + unload_ok = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS_BY_TYPE[sensor_type] + ) + if sensor_type == ATTR_TYPE_CLOUD and unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -def _get_ezviz_client_instance(entry: ConfigEntry) -> EzvizClient: - """Initialize a new instance of EzvizClientApi.""" - ezviz_client = EzvizClient( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_URL], - entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ) - ezviz_client.login() - return ezviz_client diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 7901061c02..0456e7ade9 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -34,7 +34,6 @@ from .const import ( DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_RTSP_PORT, DIR_DOWN, DIR_LEFT, DIR_RIGHT, @@ -70,24 +69,17 @@ async def async_setup_entry( if item.unique_id == camera and item.source != SOURCE_IGNORE ] - # There seem to be a bug related to localRtspPort in EZVIZ API. - local_rtsp_port = ( - value["local_rtsp_port"] - if value["local_rtsp_port"] != 0 - else DEFAULT_RTSP_PORT - ) - if camera_rtsp_entry: ffmpeg_arguments = camera_rtsp_entry[0].options[CONF_FFMPEG_ARGUMENTS] camera_username = camera_rtsp_entry[0].data[CONF_USERNAME] camera_password = camera_rtsp_entry[0].data[CONF_PASSWORD] - camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{local_rtsp_port}{ffmpeg_arguments}" + camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{value['local_rtsp_port']}{ffmpeg_arguments}" _LOGGER.debug( "Configuring Camera %s with ip: %s rtsp port: %s ffmpeg arguments: %s", camera, value["local_ip"], - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) @@ -123,7 +115,7 @@ async def async_setup_entry( camera_username, camera_password, camera_rtsp_stream, - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) ) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 4c8b1418fa..77598ad6a1 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -1,12 +1,14 @@ -"""Config flow for ezviz.""" +"""Config flow for EZVIZ.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from pyezviz.client import EzvizClient from pyezviz.exceptions import ( AuthTestResultFailed, - HTTPError, + EzvizAuthVerificationCode, InvalidHost, InvalidURL, PyEzvizError, @@ -25,12 +27,15 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, @@ -40,23 +45,37 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +DEFAULT_OPTIONS = { + CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, + CONF_TIMEOUT: DEFAULT_TIMEOUT, +} -def _get_ezviz_client_instance(data): - """Initialize a new instance of EzvizClientApi.""" +def _validate_and_create_auth(data: dict) -> dict[str, Any]: + """Try to login to EZVIZ cloud account and return token.""" + # Verify cloud credentials by attempting a login request with username and password. + # Return login token. ezviz_client = EzvizClient( data[CONF_USERNAME], data[CONF_PASSWORD], - data.get(CONF_URL, EU_URL), + data[CONF_URL], data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - ezviz_client.login() - return ezviz_client + ezviz_token = ezviz_client.login() + + auth_data = { + CONF_SESSION_ID: ezviz_token[CONF_SESSION_ID], + CONF_RFSESSION_ID: ezviz_token[CONF_RFSESSION_ID], + CONF_URL: ezviz_token["api_url"], + CONF_TYPE: ATTR_TYPE_CLOUD, + } + + return auth_data -def _test_camera_rtsp_creds(data): +def _test_camera_rtsp_creds(data: dict) -> None: """Try DESCRIBE on RTSP camera with credentials.""" test_rtsp = TestRTSPAuth( @@ -71,89 +90,43 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _validate_and_create_auth(self, data): - """Try to login to ezviz cloud account and create entry if successful.""" - await self.async_set_unique_id(data[CONF_USERNAME]) - self._abort_if_unique_id_configured() - - # Verify cloud credentials by attempting a login request. - try: - await self.hass.async_add_executor_job(_get_ezviz_client_instance, data) - - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - auth_data = { - CONF_USERNAME: data[CONF_USERNAME], - CONF_PASSWORD: data[CONF_PASSWORD], - CONF_URL: data.get(CONF_URL, EU_URL), - CONF_TYPE: ATTR_TYPE_CLOUD, - } - - return self.async_create_entry(title=data[CONF_USERNAME], data=auth_data) - - async def _validate_and_create_camera_rtsp(self, data): + async def _validate_and_create_camera_rtsp(self, data: dict) -> FlowResult: """Try DESCRIBE on RTSP camera with credentials.""" # Get EZVIZ cloud credentials from config entry - ezviz_client_creds = { - CONF_USERNAME: None, - CONF_PASSWORD: None, - CONF_URL: None, + ezviz_token = { + CONF_SESSION_ID: None, + CONF_RFSESSION_ID: None, + "api_url": None, } + ezviz_timeout = DEFAULT_TIMEOUT for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - ezviz_client_creds = { - CONF_USERNAME: item.data.get(CONF_USERNAME), - CONF_PASSWORD: item.data.get(CONF_PASSWORD), - CONF_URL: item.data.get(CONF_URL), + ezviz_token = { + CONF_SESSION_ID: item.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: item.data.get(CONF_RFSESSION_ID), + "api_url": item.data.get(CONF_URL), } + ezviz_timeout = item.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) # Abort flow if user removed cloud account before adding camera. - if ezviz_client_creds[CONF_USERNAME] is None: + if ezviz_token.get(CONF_SESSION_ID) is None: return self.async_abort(reason="ezviz_cloud_account_missing") + ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout) + # We need to wake hibernating cameras. # First create EZVIZ API instance. - try: - ezviz_client = await self.hass.async_add_executor_job( - _get_ezviz_client_instance, ezviz_client_creds - ) + await self.hass.async_add_executor_job(ezviz_client.login) - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - # Secondly try to wake hibernating camera. - try: - await self.hass.async_add_executor_job( - ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] - ) - - except HTTPError as err: - raise InvalidHost from err + # Secondly try to wake hybernating camera. + await self.hass.async_add_executor_job( + ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] + ) # Thirdly attempts an authenticated RTSP DESCRIBE request. - try: - await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) - - except InvalidHost as err: - raise InvalidHost from err - - except AuthTestResultFailed as err: - raise AuthTestResultFailed from err + await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) return self.async_create_entry( title=data[ATTR_SERIAL], @@ -162,6 +135,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): CONF_PASSWORD: data[CONF_PASSWORD], CONF_TYPE: ATTR_TYPE_CAMERA, }, + options=DEFAULT_OPTIONS, ) @staticmethod @@ -170,18 +144,24 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return EzvizOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" - # Check if ezviz cloud account is present in entry config, + # Check if EZVIZ cloud account is present in entry config, # abort if already configured. for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: return self.async_abort(reason="already_configured_account") errors = {} + auth_data = {} if user_input is not None: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + if user_input[CONF_URL] == CONF_CUSTOMIZE: self.context["data"] = { CONF_USERNAME: user_input[CONF_USERNAME], @@ -189,11 +169,10 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): } return await self.async_step_user_custom_url() - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -201,6 +180,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -208,6 +190,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -222,20 +211,21 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) - async def async_step_user_custom_url(self, user_input=None): + async def async_step_user_custom_url( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user for custom region url.""" - errors = {} + auth_data = {} if user_input is not None: user_input[CONF_USERNAME] = self.context["data"][CONF_USERNAME] user_input[CONF_PASSWORD] = self.context["data"][CONF_PASSWORD] - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -243,6 +233,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -250,6 +243,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema_custom_url = vol.Schema( { vol.Required(CONF_URL, default=EU_URL): str, @@ -260,18 +260,22 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user_custom_url", data_schema=data_schema_custom_url, errors=errors ) - async def async_step_integration_discovery(self, discovery_info): + async def async_step_integration_discovery( + self, discovery_info: dict[str, Any] + ) -> FlowResult: """Handle a flow for discovered camera without rtsp config entry.""" await self.async_set_unique_id(discovery_info[ATTR_SERIAL]) self._abort_if_unique_id_configured() - self.context["title_placeholders"] = {"serial": self.unique_id} + self.context["title_placeholders"] = {ATTR_SERIAL: self.unique_id} self.context["data"] = {CONF_IP_ADDRESS: discovery_info[CONF_IP_ADDRESS]} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm and create entry from discovery step.""" errors = {} @@ -284,6 +288,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except (InvalidHost, InvalidURL): errors["base"] = "invalid_host" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except (PyEzvizError, AuthTestResultFailed): errors["base"] = "invalid_auth" @@ -303,11 +310,76 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=discovered_camera_schema, errors=errors, description_placeholders={ - "serial": self.unique_id, + ATTR_SERIAL: self.unique_id, CONF_IP_ADDRESS: self.context["data"][CONF_IP_ADDRESS], }, ) + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle a flow for reauthentication with password.""" + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a Confirm flow for reauthentication with password.""" + auth_data = {} + errors = {} + entry = None + + for item in self._async_current_entries(): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + self.context["title_placeholders"] = {ATTR_SERIAL: item.title} + entry = await self.async_set_unique_id(item.title) + + if not entry: + return self.async_abort(reason="ezviz_cloud_account_missing") + + if user_input is not None: + user_input[CONF_URL] = entry.data[CONF_URL] + + try: + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) + + except (InvalidHost, InvalidURL): + errors["base"] = "invalid_host" + + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + + except (PyEzvizError, AuthTestResultFailed): + errors["base"] = "invalid_auth" + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + else: + self.hass.config_entries.async_update_entry( + entry, + data=auth_data, + ) + + await self.hass.config_entries.async_reload(entry.entry_id) + + return self.async_abort(reason="reauth_successful") + + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME, default=entry.title): vol.In([entry.title]), + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + errors=errors, + ) + class EzvizOptionsFlowHandler(OptionsFlow): """Handle EZVIZ client options.""" @@ -316,22 +388,28 @@ class EzvizOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage EZVIZ options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) - options = { - vol.Optional( - CONF_TIMEOUT, - default=self.config_entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ): int, - vol.Optional( - CONF_FFMPEG_ARGUMENTS, - default=self.config_entry.options.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ), - ): str, - } + options = vol.Schema( + { + vol.Optional( + CONF_TIMEOUT, + default=self.config_entry.options.get( + CONF_TIMEOUT, DEFAULT_TIMEOUT + ), + ): int, + vol.Optional( + CONF_FFMPEG_ARGUMENTS, + default=self.config_entry.options.get( + CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS + ), + ): str, + } + ) - return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + return self.async_show_form(step_id="init", data_schema=options) diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index b9183772b6..d052a4b821 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -10,6 +10,9 @@ ATTR_HOME = "HOME_MODE" ATTR_AWAY = "AWAY_MODE" ATTR_TYPE_CLOUD = "EZVIZ_CLOUD_ACCOUNT" ATTR_TYPE_CAMERA = "CAMERA_ACCOUNT" +CONF_SESSION_ID = "session_id" +CONF_RFSESSION_ID = "rf_session_id" +CONF_EZVIZ_ACCOUNT = "ezviz_account" # Services data DIR_UP = "up" @@ -33,10 +36,8 @@ SERVICE_DETECTION_SENSITIVITY = "set_alarm_detection_sensibility" EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" -DEFAULT_RTSP_PORT = 554 DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" # Data DATA_COORDINATOR = "coordinator" -DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index cc4537bb9b..ba8ed336a5 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -4,9 +4,16 @@ import logging from async_timeout import timeout from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -27,15 +34,16 @@ class EzvizDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - def _update_data(self) -> dict: - """Fetch data from EZVIZ via camera load function.""" - return self.ezviz_client.load_cameras() - async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" try: async with timeout(self._api_timeout): - return await self.hass.async_add_executor_job(self._update_data) + return await self.hass.async_add_executor_job( + self.ezviz_client.load_cameras + ) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error except (InvalidURL, HTTPError, PyEzvizError) as error: raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 91fa32ad9b..5e258e4270 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -26,17 +26,27 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Enter credentials to reauthenticate to ezviz cloud account", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "mfa_required": "2FA enabled on account, please disable and retry" }, "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account" + "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/ezviz/__init__.py b/tests/components/ezviz/__init__.py index 64dcbfc26e..768fc30cc8 100644 --- a/tests/components/ezviz/__init__.py +++ b/tests/components/ezviz/__init__.py @@ -3,8 +3,11 @@ from unittest.mock import patch from homeassistant.components.ezviz.const import ( ATTR_SERIAL, + ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -22,8 +25,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry ENTRY_CONFIG = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_SESSION_ID: "test-username", + CONF_RFSESSION_ID: "test-password", CONF_URL: "apiieu.ezvizlife.com", CONF_TYPE: ATTR_TYPE_CLOUD, } @@ -46,6 +49,18 @@ USER_INPUT = { CONF_TYPE: ATTR_TYPE_CLOUD, } +USER_INPUT_CAMERA_VALIDATE = { + ATTR_SERIAL: "C666666", + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", +} + +USER_INPUT_CAMERA = { + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", + CONF_TYPE: ATTR_TYPE_CAMERA, +} + DISCOVERY_INFO = { ATTR_SERIAL: "C666666", CONF_USERNAME: None, @@ -59,6 +74,13 @@ TEST = { CONF_IP_ADDRESS: "127.0.0.1", } +API_LOGIN_RETURN_VALIDATE = { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, +} + def _patch_async_setup_entry(return_value=True): return patch( diff --git a/tests/components/ezviz/conftest.py b/tests/components/ezviz/conftest.py index 76b962250b..e89e375fb5 100644 --- a/tests/components/ezviz/conftest.py +++ b/tests/components/ezviz/conftest.py @@ -5,6 +5,12 @@ from pyezviz import EzvizClient from pyezviz.test_cam_rtsp import TestRTSPAuth import pytest +ezviz_login_token_return = { + "session_id": "fake_token", + "rf_session_id": "fake_rf_token", + "api_url": "apiieu.ezvizlife.com", +} + @pytest.fixture(autouse=True) def mock_ffmpeg(hass): @@ -42,7 +48,7 @@ def ezviz_config_flow(hass): "1", ) - instance.login = MagicMock(return_value=True) + instance.login = MagicMock(return_value=ezviz_login_token_return) instance.get_detection_sensibility = MagicMock(return_value=True) yield mock_ezviz diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 624827220c..939bb92bcc 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from pyezviz.exceptions import ( AuthTestResultFailed, + EzvizAuthVerificationCode, HTTPError, InvalidHost, InvalidURL, @@ -12,13 +13,16 @@ from pyezviz.exceptions import ( from homeassistant.components.ezviz.const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, - ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, ) -from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_INTEGRATION_DISCOVERY, + SOURCE_REAUTH, + SOURCE_USER, +) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -32,8 +36,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + API_LOGIN_RETURN_VALIDATE, DISCOVERY_INFO, - USER_INPUT, USER_INPUT_VALIDATE, _patch_async_setup_entry, init_integration, @@ -59,7 +63,7 @@ async def test_user_form(hass: HomeAssistant, ezviz_config_flow) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" - assert result["data"] == {**USER_INPUT} + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} assert len(mock_setup_entry.mock_calls) == 1 @@ -78,7 +82,11 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass", CONF_URL: "customize"}, + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: CONF_CUSTOMIZE, + }, ) assert result["type"] == FlowResultType.FORM @@ -90,21 +98,58 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result["flow_id"], {CONF_URL: "test-user"}, ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_PASSWORD: "test-pass", - CONF_TYPE: ATTR_TYPE_CLOUD, - CONF_URL: "test-user", - CONF_USERNAME: "test-user", - } + assert result["data"] == API_LOGIN_RETURN_VALIDATE assert len(mock_setup_entry.mock_calls) == 1 -async def test_step_discovery_abort_if_cloud_account_missing( - hass: HomeAssistant, -) -> None: +async def test_async_step_reauth(hass, ezviz_config_flow): + """Test the reauth step.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_step_discovery_abort_if_cloud_account_missing(hass): """Test discovery and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( @@ -127,11 +172,21 @@ async def test_step_discovery_abort_if_cloud_account_missing( assert result["reason"] == "ezviz_cloud_account_missing" +async def test_step_reauth_abort_if_cloud_account_missing(hass): + """Test reauth and confirm step, abort if cloud account was removed.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "ezviz_cloud_account_missing" + + async def test_async_step_integration_discovery( - hass: HomeAssistant, ezviz_config_flow, ezviz_test_rtsp_config_flow -) -> None: + hass, ezviz_config_flow, ezviz_test_rtsp_config_flow +): """Test discovery and confirm step.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -189,11 +244,14 @@ async def test_options_flow(hass: HomeAssistant) -> None: async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> None: """Test we handle exception on user form.""" - ezviz_config_flow.side_effect = PyEzvizError - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + ezviz_config_flow.side_effect = PyEzvizError result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -215,6 +273,17 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["step_id"] == "user" assert result["errors"] == {"base": "invalid_host"} + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "mfa_required"} + ezviz_config_flow.side_effect = HTTPError result = await hass.config_entries.flow.async_configure( @@ -224,7 +293,7 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} ezviz_config_flow.side_effect = Exception @@ -242,7 +311,7 @@ async def test_discover_exception_step1( ezviz_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -295,7 +364,21 @@ async def test_discover_exception_step1( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-pass", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -317,7 +400,7 @@ async def test_discover_exception_step3( ezviz_test_rtsp_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -423,7 +506,18 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user_custom_url" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -434,3 +528,103 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "unknown" + + +async def test_async_step_reauth_exception(hass, ezviz_config_flow): + """Test the reauth step exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + ezviz_config_flow.side_effect = InvalidURL() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = InvalidHost() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "mfa_required"} + + ezviz_config_flow.side_effect = PyEzvizError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = Exception() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" From fc78290e2f425aaeed11b61255b2f0adf8f3be1c Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 29 Mar 2023 18:04:39 -0400 Subject: [PATCH 0325/1165] Remove callback decorators where unneeded (#90478) * Remove callback decorators where unneeded * revert extra replace --- homeassistant/components/zwave_js/api.py | 4 ++-- homeassistant/components/zwave_js/helpers.py | 9 +-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 91b1e2a715..29e0dcf9e0 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -82,8 +82,8 @@ from .const import ( from .helpers import ( async_enable_statistics, async_get_node_from_device_id, + async_update_data_collection_preference, get_device_id, - update_data_collection_preference, ) DATA_UNSUBSCRIBE = "unsubs" @@ -1860,7 +1860,7 @@ async def websocket_update_data_collection_preference( ) -> None: """Update preference for data collection and enable/disable collection.""" opted_in = msg[OPTED_IN] - update_data_collection_preference(hass, entry, opted_in) + async_update_data_collection_preference(hass, entry, opted_in) if opted_in: await async_enable_statistics(driver) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index d856e987af..6c54a46483 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -92,7 +92,6 @@ def value_matches_matcher( ) -@callback def get_value_id_from_unique_id(unique_id: str) -> str | None: """Get the value ID and optional state key from a unique ID. @@ -106,7 +105,6 @@ def get_value_id_from_unique_id(unique_id: str) -> str | None: return None -@callback def get_state_key_from_unique_id(unique_id: str) -> int | None: """Get the state key from a unique ID.""" # If the unique ID has more than two parts, it's a special unique ID. If the last @@ -119,7 +117,6 @@ def get_state_key_from_unique_id(unique_id: str) -> int | None: return None -@callback def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: """Return the value of a ZwaveValue.""" return value.value if value else None @@ -132,7 +129,7 @@ async def async_enable_statistics(driver: Driver) -> None: @callback -def update_data_collection_preference( +def async_update_data_collection_preference( hass: HomeAssistant, entry: ConfigEntry, preference: bool ) -> None: """Update data collection preference on config entry.""" @@ -141,7 +138,6 @@ def update_data_collection_preference( hass.config_entries.async_update_entry(entry, data=new_data) -@callback def get_valueless_base_unique_id(driver: Driver, node: ZwaveNode) -> str: """Return the base unique ID for an entity that is not based on a value.""" return f"{driver.controller.home_id}.{node.node_id}" @@ -152,13 +148,11 @@ def get_unique_id(driver: Driver, value_id: str) -> str: return f"{driver.controller.home_id}.{value_id}" -@callback def get_device_id(driver: Driver, node: ZwaveNode) -> tuple[str, str]: """Get device registry identifier for Z-Wave node.""" return (DOMAIN, f"{driver.controller.home_id}-{node.node_id}") -@callback def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None: """Get extended device registry identifier for Z-Wave node.""" if None in (node.manufacturer_id, node.product_type, node.product_id): @@ -171,7 +165,6 @@ def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None ) -@callback def get_home_and_node_id_from_device_entry( device_entry: dr.DeviceEntry, ) -> tuple[str, int] | None: From f0710bae06e1d02df8fa70eb758b9386517d204e Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 30 Mar 2023 07:42:09 +0200 Subject: [PATCH 0326/1165] Add config-flow to Snapcast (#80288) * initial stab at snapcast config flow * fix linting errors * Fix linter errors * Add import flow, support unloading * Add test for import flow * Add dataclass and remove unique ID in config-flow * remove translations * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Refactor config flow and terminate connection * Rename test_config_flow.py * Fix tests * Minor fixes * Make mock_create_server a fixture * Combine tests * Abort if entry already exists * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Move HomeAssistantSnapcast to own file. Clean-up last commit * Split import flow from user flow. Fix tests. * Use explicit asserts. Add default values to dataclass * Change entry title to Snapcast --------- Co-authored-by: Barrett Lowe Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 4 +- CODEOWNERS | 1 + homeassistant/components/snapcast/__init__.py | 42 ++++++- .../components/snapcast/config_flow.py | 63 ++++++++++ homeassistant/components/snapcast/const.py | 6 +- .../components/snapcast/manifest.json | 1 + .../components/snapcast/media_player.py | 87 +++++++++----- homeassistant/components/snapcast/server.py | 15 +++ .../components/snapcast/strings.json | 27 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/snapcast/__init__.py | 1 + tests/components/snapcast/conftest.py | 23 ++++ tests/components/snapcast/test_config_flow.py | 110 ++++++++++++++++++ 15 files changed, 352 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/snapcast/config_flow.py create mode 100644 homeassistant/components/snapcast/server.py create mode 100644 homeassistant/components/snapcast/strings.json create mode 100644 tests/components/snapcast/__init__.py create mode 100644 tests/components/snapcast/conftest.py create mode 100644 tests/components/snapcast/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2c06c7d0bb..d313da55dd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1101,7 +1101,9 @@ omit = homeassistant/components/sms/notify.py homeassistant/components/sms/sensor.py homeassistant/components/smtp/notify.py - homeassistant/components/snapcast/* + homeassistant/components/snapcast/__init__.py + homeassistant/components/snapcast/media_player.py + homeassistant/components/snapcast/server.py homeassistant/components/snmp/device_tracker.py homeassistant/components/snmp/sensor.py homeassistant/components/snmp/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 509f3e5f30..0e918caade 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1105,6 +1105,7 @@ build.json @home-assistant/supervisor /tests/components/smhi/ @gjohansson-ST /homeassistant/components/sms/ @ocalvo /homeassistant/components/snapcast/ @luar123 +/tests/components/snapcast/ @luar123 /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index b5279fa3ce..309669a849 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -1 +1,41 @@ -"""The snapcast component.""" +"""Snapcast Integration.""" +import logging + +import snapcast.control + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS +from .server import HomeAssistantSnapcast + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Snapcast from a config entry.""" + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + try: + server = await snapcast.control.create_server( + hass.loop, host, port, reconnect=True + ) + except OSError as ex: + raise ConfigEntryNotReady( + f"Could not connect to Snapcast server at {host}:{port}" + ) from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(server) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py new file mode 100644 index 0000000000..896d3f8b5a --- /dev/null +++ b/homeassistant/components/snapcast/config_flow.py @@ -0,0 +1,63 @@ +"""Snapcast config flow.""" + +from __future__ import annotations + +import logging +import socket + +import snapcast.control +from snapcast.control.server import CONTROL_PORT +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_TITLE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SNAPCAST_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=CONTROL_PORT): int, + } +) + + +class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN): + """Snapcast config flow.""" + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle first step.""" + errors = {} + if user_input: + self._async_abort_entries_match(user_input) + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + + # Attempt to create the server - make sure it's going to work + try: + client = await snapcast.control.create_server( + self.hass.loop, host, port, reconnect=False + ) + except socket.gaierror: + errors["base"] = "invalid_host" + except OSError: + errors["base"] = "cannot_connect" + else: + await client.stop() + return self.async_create_entry(title=DEFAULT_TITLE, data=user_input) + return self.async_show_form( + step_id="user", data_schema=SNAPCAST_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._async_abort_entries_match( + { + CONF_HOST: (import_config[CONF_HOST]), + CONF_PORT: (import_config[CONF_PORT]), + } + ) + return self.async_create_entry(title=DEFAULT_TITLE, data=import_config) diff --git a/homeassistant/components/snapcast/const.py b/homeassistant/components/snapcast/const.py index 674a22993b..ded57e6fb0 100644 --- a/homeassistant/components/snapcast/const.py +++ b/homeassistant/components/snapcast/const.py @@ -1,6 +1,7 @@ """Constants for Snapcast.""" +from homeassistant.const import Platform -DATA_KEY = "snapcast" +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] GROUP_PREFIX = "snapcast_group_" GROUP_SUFFIX = "Snapcast Group" @@ -15,3 +16,6 @@ SERVICE_SET_LATENCY = "set_latency" ATTR_MASTER = "master" ATTR_LATENCY = "latency" + +DOMAIN = "snapcast" +DEFAULT_TITLE = "Snapcast" diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index bdcadc84e7..8701fca0ad 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -2,6 +2,7 @@ "domain": "snapcast", "name": "Snapcast", "codeowners": ["@luar123"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/snapcast", "iot_class": "local_polling", "loggers": ["construct", "snapcast"], diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 9e0e10ac0e..6f965155bb 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -2,9 +2,7 @@ from __future__ import annotations import logging -import socket -import snapcast.control from snapcast.control.server import CONTROL_PORT import voluptuous as vol @@ -14,10 +12,12 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -25,7 +25,7 @@ from .const import ( ATTR_MASTER, CLIENT_PREFIX, CLIENT_SUFFIX, - DATA_KEY, + DOMAIN, GROUP_PREFIX, GROUP_SUFFIX, SERVICE_JOIN, @@ -34,6 +34,7 @@ from .const import ( SERVICE_SNAPSHOT, SERVICE_UNJOIN, ) +from .server import HomeAssistantSnapcast _LOGGER = logging.getLogger(__name__) @@ -42,18 +43,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Snapcast platform.""" - - host = config.get(CONF_HOST) - port = config.get(CONF_PORT, CONTROL_PORT) - +def register_services(): + """Register snapcast services.""" platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service(SERVICE_SNAPSHOT, {}, "snapshot") platform.async_register_entity_service(SERVICE_RESTORE, {}, "async_restore") platform.async_register_entity_service( @@ -66,23 +59,55 @@ async def async_setup_platform( handle_set_latency, ) - try: - server = await snapcast.control.create_server( - hass.loop, host, port, reconnect=True - ) - except socket.gaierror: - _LOGGER.error("Could not connect to Snapcast server at %s:%d", host, port) - return - # Note: Host part is needed, when using multiple snapservers +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the snapcast config entry.""" + snapcast_data: HomeAssistantSnapcast = hass.data[DOMAIN][config_entry.entry_id] + + register_services() + + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] hpid = f"{host}:{port}" - devices: list[MediaPlayerEntity] = [ - SnapcastGroupDevice(group, hpid) for group in server.groups + snapcast_data.groups = [ + SnapcastGroupDevice(group, hpid) for group in snapcast_data.server.groups ] - devices.extend(SnapcastClientDevice(client, hpid) for client in server.clients) - hass.data[DATA_KEY] = devices - async_add_entities(devices) + snapcast_data.clients = [ + SnapcastClientDevice(client, hpid, config_entry.entry_id) + for client in snapcast_data.server.clients + ] + async_add_entities(snapcast_data.clients + snapcast_data.groups) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Snapcast platform.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + config[CONF_PORT] = config.get(CONF_PORT, CONTROL_PORT) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) async def handle_async_join(entity, service_call): @@ -211,10 +236,11 @@ class SnapcastClientDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, client, uid_part): + def __init__(self, client, uid_part, entry_id): """Initialize the Snapcast client device.""" self._client = client self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" + self._entry_id = entry_id async def async_added_to_hass(self) -> None: """Subscribe to client events.""" @@ -303,9 +329,10 @@ class SnapcastClientDevice(MediaPlayerEntity): async def async_join(self, master): """Join the group of the master player.""" - master_entity = next( - entity for entity in self.hass.data[DATA_KEY] if entity.entity_id == master + entity + for entity in self.hass.data[DOMAIN][self._entry_id].clients + if entity.entity_id == master ) if not isinstance(master_entity, SnapcastClientDevice): raise TypeError("Master is not a client device. Can only join clients.") diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py new file mode 100644 index 0000000000..507ad6393a --- /dev/null +++ b/homeassistant/components/snapcast/server.py @@ -0,0 +1,15 @@ +"""Snapcast Integration.""" +from dataclasses import dataclass, field + +from snapcast.control import Snapserver + +from homeassistant.components.media_player import MediaPlayerEntity + + +@dataclass +class HomeAssistantSnapcast: + """Snapcast data stored in the Home Assistant data object.""" + + server: Snapserver + clients: list[MediaPlayerEntity] = field(default_factory=list) + groups: list[MediaPlayerEntity] = field(default_factory=list) diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json new file mode 100644 index 0000000000..0087b70d82 --- /dev/null +++ b/homeassistant/components/snapcast/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "description": "Please enter your server connection details", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "title": "Connect" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Snapcast YAML configuration is being removed", + "description": "Configuring Snapcast using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Snapcast YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 240e30ec86..2f84b0b10d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -399,6 +399,7 @@ FLOWS = { "smarttub", "smhi", "sms", + "snapcast", "snooz", "solaredge", "solarlog", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bc0e9e1c33..02273b8d97 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5061,7 +5061,7 @@ "snapcast": { "name": "Snapcast", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "snips": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b819133fc7..314cd2efdf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1687,6 +1687,9 @@ smart-meter-texas==0.4.7 # homeassistant.components.smhi smhi-pkg==1.0.16 +# homeassistant.components.snapcast +snapcast==2.3.2 + # homeassistant.components.sonos soco==0.29.1 diff --git a/tests/components/snapcast/__init__.py b/tests/components/snapcast/__init__.py new file mode 100644 index 0000000000..a325bd41bd --- /dev/null +++ b/tests/components/snapcast/__init__.py @@ -0,0 +1 @@ +"""Tests for the Snapcast integration.""" diff --git a/tests/components/snapcast/conftest.py b/tests/components/snapcast/conftest.py new file mode 100644 index 0000000000..00d031192d --- /dev/null +++ b/tests/components/snapcast/conftest.py @@ -0,0 +1,23 @@ +"""Test the snapcast config flow.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.snapcast.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_create_server() -> Generator[AsyncMock, None, None]: + """Create mock snapcast connection.""" + mock_connection = AsyncMock() + mock_connection.start = AsyncMock(return_value=None) + with patch("snapcast.control.create_server", return_value=mock_connection): + yield mock_connection diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py new file mode 100644 index 0000000000..b6ff43503a --- /dev/null +++ b/tests/components/snapcast/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test the Snapcast module.""" + +import socket +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.snapcast.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_CONNECTION = {CONF_HOST: "snapserver.test", CONF_PORT: 1705} + +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_create_server") + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock +) -> None: + """Test we get the form and handle errors and successful connection.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + # test invalid host error + with patch("snapcast.control.create_server", side_effect=socket.gaierror): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_host"} + + # test connection error + with patch("snapcast.control.create_server", side_effect=ConnectionRefusedError): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # test success + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Snapcast" + assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} + assert len(mock_create_server.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock +) -> None: + """Test config flow abort if device is already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONNECTION, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + with patch("snapcast.control.create_server", side_effect=socket.gaierror): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import(hass: HomeAssistant) -> None: + """Test successful import.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Snapcast" + assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} From 40cb0eeb68d20fc3367a21efa1b85dda6c1f089a Mon Sep 17 00:00:00 2001 From: Thijs W Date: Thu, 30 Mar 2023 08:05:24 +0200 Subject: [PATCH 0327/1165] Add missing strings in frontier_silicon (#90446) Improve confirm message for ssdp flow --- homeassistant/components/frontier_silicon/config_flow.py | 4 +++- homeassistant/components/frontier_silicon/strings.json | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index a054bd2b30..0ccc61e99c 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -188,7 +188,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self._async_create_entry() self._set_confirm_only() - return self.async_show_form(step_id="confirm") + return self.async_show_form( + step_id="confirm", description_placeholders={"name": self._name} + ) async def async_step_device_config( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 3a0a504761..a7c3f3e439 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -13,6 +13,9 @@ "data": { "pin": "[%key:common::config_flow::data::pin%]" } + }, + "confirm": { + "description": "Do you want to set up {name}?" } }, "error": { From 053ed3cfdc0550fff487e1602018017c30162a2c Mon Sep 17 00:00:00 2001 From: Thijs W Date: Thu, 30 Mar 2023 08:49:46 +0200 Subject: [PATCH 0328/1165] Add reauth to frontier_silicon config flow (#90443) * Add reauth to frontier_silicon config flow * Update patch target * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Add reauth_successful to strings.json * Don't manually set "title_placeholders" Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../frontier_silicon/config_flow.py | 20 +++++ .../components/frontier_silicon/strings.json | 3 +- .../frontier_silicon/test_config_flow.py | 79 +++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 0ccc61e99c..7067f88297 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Frontier Silicon Media Player integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse @@ -53,6 +54,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _name: str _webfsapi_url: str + _reauth_entry: config_entries.ConfigEntry | None = None # Only used in reauth flows async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: """Handle the import of legacy configuration.yaml entries.""" @@ -192,6 +194,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="confirm", description_placeholders={"name": self._name} ) + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._webfsapi_url = config[CONF_WEBFSAPI_URL] + + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + return await self.async_step_device_config() + async def async_step_device_config( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -220,6 +232,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(exception) errors["base"] = "unknown" else: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={CONF_PIN: user_input[CONF_PIN]}, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + unique_id = await afsapi.get_radio_id() await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index a7c3f3e439..f40abe1675 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -24,7 +24,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "issues": { diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index 612058af0a..524b985b12 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -415,3 +415,82 @@ async def test_ssdp_nondefault_pin(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "invalid_auth" + + +async def test_reauth_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_PIN] == "1234" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "device_config" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "4242"}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_PIN] == "4242" + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (ConnectionError, "cannot_connect"), + (InvalidPinException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_reauth_flow_friendly_name_error( + hass: HomeAssistant, + exception: Exception, + reason: str, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with failures.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_PIN] == "1234" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "device_config" + + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PIN: "4321"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "device_config" + assert result2["errors"] == {"base": reason} + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "4242"}, + ) + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert config_entry.data[CONF_PIN] == "4242" From ba32e28fc67fe27a0c94448dfa62deb6a4059d82 Mon Sep 17 00:00:00 2001 From: jellenijhof12 Date: Thu, 30 Mar 2023 08:59:29 +0200 Subject: [PATCH 0329/1165] Add dimmable lights support to niko home control (#90141) * added support for dimmable lights and auto host discover * split up merge request * fixed feedback brightness support * fixed feedback Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * resolved feedback --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/niko_home_control/light.py | 47 +++++++++---------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 4d12591a47..b541a145a6 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -8,8 +8,13 @@ from typing import Any import nikohomecontrol import voluptuous as vol -# Import the device class from the component that you want to support -from homeassistant.components.light import PLATFORM_SCHEMA, ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + ColorMode, + LightEntity, + brightness_supported, +) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady @@ -52,36 +57,23 @@ async def async_setup_platform( class NikoHomeControlLight(LightEntity): """Representation of an Niko Light.""" - _attr_color_mode = ColorMode.ONOFF - _attr_supported_color_modes = {ColorMode.ONOFF} - def __init__(self, light, data): """Set up the Niko Home Control light platform.""" self._data = data self._light = light - self._unique_id = f"light-{light.id}" - self._name = light.name - self._state = light.is_on - - @property - def unique_id(self): - """Return unique ID for light.""" - return self._unique_id - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def is_on(self): - """Return true if light is on.""" - return self._state + self._attr_unique_id = f"light-{light.id}" + self._attr_name = light.name + self._attr_is_on = light.is_on + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {ColorMode.ONOFF} + if light._state["type"] == 2: + self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" _LOGGER.debug("Turn on: %s", self.name) - self._light.turn_on() + self._light.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" @@ -91,7 +83,10 @@ class NikoHomeControlLight(LightEntity): async def async_update(self) -> None: """Get the latest data from NikoHomeControl API.""" await self._data.async_update() - self._state = self._data.get_state(self._light.id) + state = self._data.get_state(self._light.id) + self._attr_is_on = state != 0 + if brightness_supported(self.supported_color_modes): + self._attr_brightness = state * 2.55 class NikoHomeControlData: @@ -122,5 +117,5 @@ class NikoHomeControlData: """Find and filter state based on action id.""" for state in self.data: if state["id"] == aid: - return state["value1"] != 0 + return state["value1"] _LOGGER.error("Failed to retrieve state off unknown light") From a7040a0487cb92d92838a707760b62310255cc7b Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 30 Mar 2023 09:07:47 +0200 Subject: [PATCH 0330/1165] Add Landis+Gyr MWh-readings from ultraheat-api (#89937) * Use mwh values from ultraheat api when available Remove manifest cleanup from PR Remove added device class from this PR Restore entity registry fixture Replace filter by attr_entity_registry_enabled_default * Catchup with #90182 and #90183 * Add comment explaining disabling some entities * Add parameterisation of test cases --- .../components/landisgyr_heat_meter/sensor.py | 27 +- .../snapshots/test_sensor.ambr | 307 +++++++++++++++++- .../landisgyr_heat_meter/test_sensor.py | 47 ++- 3 files changed, 369 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 244515a07d..947ab2b2a8 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -54,6 +54,15 @@ class HeatMeterSensorEntityDescription( HEAT_METER_SENSOR_TYPES = ( + HeatMeterSensorEntityDescription( + key="heat_usage_mwh", + icon="mdi:fire", + name="Heat usage MWh", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda res: getattr(res, "heat_usage_mwh", None), + ), HeatMeterSensorEntityDescription( key="volume_usage_m3", icon="mdi:fire", @@ -72,6 +81,15 @@ HEAT_METER_SENSOR_TYPES = ( state_class=SensorStateClass.TOTAL, value_fn=lambda res: getattr(res, "heat_usage_gj", None), ), + HeatMeterSensorEntityDescription( + key="heat_previous_year_mwh", + icon="mdi:fire", + name="Heat previous year MWh", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "heat_previous_year_mwh", None), + ), HeatMeterSensorEntityDescription( key="heat_previous_year_gj", icon="mdi:fire", @@ -277,7 +295,6 @@ async def async_setup_entry( ) sensors = [] - for description in HEAT_METER_SENSOR_TYPES: sensors.append(HeatMeterSensor(coordinator, description, device)) @@ -306,6 +323,14 @@ class HeatMeterSensor( self.entity_description = description self._attr_device_info = device + if ( + description.native_unit_of_measurement + in {UnitOfEnergy.GIGA_JOULE, UnitOfEnergy.MEGA_WATT_HOUR} + and self.native_value is None + ): + # Some meters will return MWh, others will return GJ. + self._attr_entity_registry_enabled_default = False + @property def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" diff --git a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr index 9c62ca3f94..d3ab9d5ade 100644 --- a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr +++ b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_create_sensors +# name: test_create_sensors[mock_heat_meter_response0] list([ StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -276,7 +276,310 @@ 'entity_id': 'sensor.heat_meter_meter_date_time', 'last_changed': , 'last_updated': , - 'state': '2022-05-20T02:41:17+00:00', + 'state': '2022-05-19T19:41:17+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Measuring range', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measuring_range', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Settings and firmware', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_settings_and_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_create_sensors[mock_heat_meter_response1] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Meter Heat usage MWh', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_usage_mwh', + 'last_changed': , + 'last_updated': , + 'state': '123.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage', + 'last_changed': , + 'last_updated': , + 'state': '456.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Meter Heat previous year MWh', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_previous_year_mwh', + 'last_changed': , + 'last_updated': , + 'state': '111.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage previous year', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Ownership number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_ownership_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Error number', + 'icon': 'mdi:home-alert', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_error_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Device number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_device_number', + 'last_changed': , + 'last_updated': , + 'state': 'devicenr_789', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Measurement period minutes', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measurement_period_minutes', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max previous year', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Operating hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_operating_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Flow hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Yearly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_yearly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Monthly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_monthly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Heat Meter Meter date time', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_meter_date_time', + 'last_changed': , + 'last_updated': , + 'state': '2022-05-19T19:41:17+00:00', }), StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 4de58a206e..9f4ad24124 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -3,6 +3,7 @@ from dataclasses import dataclass import datetime from unittest.mock import patch +import pytest import serial from syrupy import SnapshotAssertion @@ -25,19 +26,49 @@ API_HEAT_METER_SERVICE = ( class MockHeatMeterResponse: """Mock for HeatMeterResponse.""" - heat_usage_gj: float + heat_usage_gj: float | None + heat_usage_mwh: float | None volume_usage_m3: float - heat_previous_year_gj: float + heat_previous_year_gj: float | None + heat_previous_year_mwh: float | None device_number: str meter_date_time: datetime.datetime +@pytest.mark.parametrize( + "mock_heat_meter_response", + [ + { + "heat_usage_gj": 123.0, + "heat_usage_mwh": None, + "volume_usage_m3": 456.0, + "heat_previous_year_gj": 111.0, + "heat_previous_year_mwh": None, + "device_number": "devicenr_789", + "meter_date_time": dt_util.as_utc( + datetime.datetime(2022, 5, 19, 19, 41, 17) + ), + }, + { + "heat_usage_gj": None, + "heat_usage_mwh": 123.0, + "volume_usage_m3": 456.0, + "heat_previous_year_gj": None, + "heat_previous_year_mwh": 111.0, + "device_number": "devicenr_789", + "meter_date_time": dt_util.as_utc( + datetime.datetime(2022, 5, 19, 19, 41, 17) + ), + }, + ], +) @patch(API_HEAT_METER_SERVICE) async def test_create_sensors( mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_heat_meter_response, ) -> None: """Test sensor.""" entry_data = { @@ -48,13 +79,7 @@ async def test_create_sensors( mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) mock_entry.add_to_hass(hass) - mock_heat_meter_response = MockHeatMeterResponse( - heat_usage_gj=123.0, - volume_usage_m3=456.0, - heat_previous_year_gj=111.0, - device_number="devicenr_789", - meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), - ) + mock_heat_meter_response = MockHeatMeterResponse(**mock_heat_meter_response) mock_heat_meter().read.return_value = mock_heat_meter_response @@ -79,8 +104,10 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non # First setup normally mock_heat_meter_response = MockHeatMeterResponse( heat_usage_gj=123.0, + heat_usage_mwh=None, volume_usage_m3=456.0, heat_previous_year_gj=111.0, + heat_previous_year_mwh=None, device_number="devicenr_789", meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), ) @@ -106,8 +133,10 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non # Now 'enable' and see if next poll succeeds mock_heat_meter_response = MockHeatMeterResponse( heat_usage_gj=124.0, + heat_usage_mwh=None, volume_usage_m3=457.0, heat_previous_year_gj=112.0, + heat_previous_year_mwh=None, device_number="devicenr_789", meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 20, 41, 17)), ) From 35995153259e8a01a8a3b8594b1cb8fdb42d230a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Mar 2023 10:21:11 +0200 Subject: [PATCH 0331/1165] Add missing strings to sensor integration (#90475) * Add missing strings to sensor integration * Enumeration * Apply suggestion Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/sensor/strings.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 5b34c5a28e..16e0da0d51 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -160,6 +160,9 @@ "energy_storage": { "name": "Stored energy" }, + "enum": { + "name": "[%key:component::sensor::title%]" + }, "frequency": { "name": "Frequency" }, @@ -235,6 +238,9 @@ "temperature": { "name": "Temperature" }, + "timestamp": { + "name": "Timestamp" + }, "volatile_organic_compounds": { "name": "VOCs" }, From 0e7d7f32c155f16090e27e65c86da2ffe77fc50b Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Thu, 30 Mar 2023 03:33:01 -0500 Subject: [PATCH 0332/1165] Add new control4 helper function (#90234) * Add new helper function to retrieve device variables and update light platform * seperate try catch from helper function and fix typing * Change helper function name * Remove unnecessary forced type changes * More type changes --- .../components/control4/director_utils.py | 32 ++++++++++++------- homeassistant/components/control4/light.py | 16 +++++++--- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index bab8c8634c..3d360e3643 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -1,5 +1,8 @@ """Provides data updates from the Control4 controller for platforms.""" +from collections import defaultdict +from collections.abc import Set import logging +from typing import Any from pyControl4.account import C4Account from pyControl4.director import C4Director @@ -15,21 +18,28 @@ from .const import CONF_ACCOUNT, CONF_CONTROLLER_UNIQUE_ID, CONF_DIRECTOR, DOMAI _LOGGER = logging.getLogger(__name__) -async def director_update_data( - hass: HomeAssistant, entry: ConfigEntry, var: str -) -> dict: - """Retrieve data from the Control4 director for update_coordinator.""" - # possibly implement usage of director_token_expiration to start - # token refresh without waiting for error to occur +async def _update_variables_for_config_entry( + hass: HomeAssistant, entry: ConfigEntry, variable_names: Set[str] +) -> dict[int, dict[str, Any]]: + """Retrieve data from the Control4 director.""" + director: C4Director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + data = await director.getAllItemVariableValue(variable_names) + result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict) + for item in data: + result_dict[item["id"]][item["varName"]] = item["value"] + return dict(result_dict) + + +async def update_variables_for_config_entry( + hass: HomeAssistant, entry: ConfigEntry, variable_names: Set[str] +) -> dict[int, dict[str, Any]]: + """Try to Retrieve data from the Control4 director for update_coordinator.""" try: - director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] - data = await director.getAllItemVariableValue(var) + return await _update_variables_for_config_entry(hass, entry, variable_names) except BadToken: _LOGGER.info("Updating Control4 director token") await refresh_tokens(hass, entry) - director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] - data = await director.getAllItemVariableValue(var) - return {key["id"]: key for key in data} + return await _update_variables_for_config_entry(hass, entry, variable_names) async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 5748664119..fde9b00aba 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -24,7 +24,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from . import Control4Entity, get_items_of_category from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN -from .director_utils import director_update_data +from .director_utils import update_variables_for_config_entry _LOGGER = logging.getLogger(__name__) @@ -47,14 +47,18 @@ async def async_setup_entry( async def async_update_data_non_dimmer(): """Fetch data from Control4 director for non-dimmer lights.""" try: - return await director_update_data(hass, entry, CONTROL4_NON_DIMMER_VAR) + return await update_variables_for_config_entry( + hass, entry, {CONTROL4_NON_DIMMER_VAR} + ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err async def async_update_data_dimmer(): """Fetch data from Control4 director for dimmer lights.""" try: - return await director_update_data(hass, entry, CONTROL4_DIMMER_VAR) + return await update_variables_for_config_entry( + hass, entry, {CONTROL4_DIMMER_VAR} + ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err @@ -185,13 +189,15 @@ class Control4Light(Control4Entity, LightEntity): @property def is_on(self): """Return whether this light is on or off.""" - return self.coordinator.data[self._idx]["value"] > 0 + if self._is_dimmer: + return self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] > 0 + return self.coordinator.data[self._idx][CONTROL4_NON_DIMMER_VAR] > 0 @property def brightness(self): """Return the brightness of this light between 0..255.""" if self._is_dimmer: - return round(self.coordinator.data[self._idx]["value"] * 2.55) + return round(self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] * 2.55) return None @property From 196f5702b8fe23a14e23d97d89ff989b5660759e Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Thu, 30 Mar 2023 12:25:14 +0300 Subject: [PATCH 0333/1165] Make hassfest.dependencies faster with multiprocessing (#81486) * hassfest.dependencies: split to two loops * hassfest.dependencies: use multiprocessing for import scan --- script/hassfest/dependencies.py | 86 ++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 8d2f179aef..28c73d890a 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast from collections import deque +import multiprocessing from pathlib import Path from homeassistant.const import Platform @@ -227,35 +228,49 @@ def find_non_referenced_integrations( return referenced -def validate_dependencies( - integrations: dict[str, Integration], +def _compute_integration_dependencies( integration: Integration, - check_dependencies: bool, -) -> None: - """Validate all dependencies.""" +) -> tuple[str, dict[Path, set[str]] | None]: + """Compute integration dependencies.""" # Some integrations are allowed to have violations. if integration.domain in IGNORE_VIOLATIONS: - return + return (integration.domain, None) # Find usage of hass.components collector = ImportCollector(integration) collector.collect() + return (integration.domain, collector.referenced) - for domain in sorted( - find_non_referenced_integrations( - integrations, integration, collector.referenced - ) - ): - integration.add_error( - "dependencies", - f"Using component {domain} but it's not in 'dependencies' " - "or 'after_dependencies'", + +def _validate_dependency_imports( + integrations: dict[str, Integration], +) -> None: + """Validate all dependencies.""" + + # Find integration dependencies with multiprocessing + # (because it takes some time to parse thousands of files) + with multiprocessing.Pool() as pool: + integration_imports = dict( + pool.imap_unordered( + _compute_integration_dependencies, + integrations.values(), + chunksize=10, + ) ) - if check_dependencies: - _check_circular_deps( - integrations, integration.domain, integration, set(), deque() - ) + for integration in integrations.values(): + referenced = integration_imports[integration.domain] + if not referenced: # Either ignored or has no references + continue + + for domain in sorted( + find_non_referenced_integrations(integrations, integration, referenced) + ): + integration.add_error( + "dependencies", + f"Using component {domain} but it's not in 'dependencies' " + "or 'after_dependencies'", + ) def _check_circular_deps( @@ -266,6 +281,7 @@ def _check_circular_deps( checking: deque[str], ) -> None: """Check for circular dependencies pointing at starting_domain.""" + if integration.domain in checked or integration.domain in checking: return @@ -297,17 +313,21 @@ def _check_circular_deps( checking.remove(integration.domain) -def validate(integrations: dict[str, Integration], config: Config) -> None: - """Handle dependencies for integrations.""" - # check for non-existing dependencies +def _validate_circular_dependencies(integrations: dict[str, Integration]) -> None: for integration in integrations.values(): - validate_dependencies( - integrations, - integration, - check_dependencies=not config.specific_integrations, + if integration.domain in IGNORE_VIOLATIONS: + continue + + _check_circular_deps( + integrations, integration.domain, integration, set(), deque() ) - if config.specific_integrations: + +def _validate_dependencies_exist( + integrations: dict[str, Integration], +) -> None: + for integration in integrations.values(): + if not integration.manifest: continue # check that all referenced dependencies exist @@ -323,3 +343,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: integration.add_error( "dependencies", f"Dependency {dep} does not exist" ) + + +def validate( + integrations: dict[str, Integration], + config: Config, +) -> None: + """Handle dependencies for integrations.""" + _validate_dependency_imports(integrations) + + if not config.specific_integrations: + _validate_dependencies_exist(integrations) + _validate_circular_dependencies(integrations) From b316ffff9bc6efa93ff536c7bd6b631157d4a4cb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 12:05:11 +0200 Subject: [PATCH 0334/1165] Rename hassfest _validate_dependencies_exist (#90503) --- script/hassfest/dependencies.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 28c73d890a..c0733841ed 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -323,14 +323,14 @@ def _validate_circular_dependencies(integrations: dict[str, Integration]) -> Non ) -def _validate_dependencies_exist( +def _validate_dependencies( integrations: dict[str, Integration], ) -> None: + """Check that all referenced dependencies exist and are not duplicated.""" for integration in integrations.values(): if not integration.manifest: continue - # check that all referenced dependencies exist after_deps = integration.manifest.get("after_dependencies", []) for dep in integration.manifest.get("dependencies", []): if dep in after_deps: @@ -353,5 +353,5 @@ def validate( _validate_dependency_imports(integrations) if not config.specific_integrations: - _validate_dependencies_exist(integrations) + _validate_dependencies(integrations) _validate_circular_dependencies(integrations) From ead88cc3f8e27169129af86cb6d1400156e68da8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 12:54:12 +0200 Subject: [PATCH 0335/1165] Add preferred wind speed unit to unit systems (#90504) * Add preferred wind speed unit to unit systems * Tweak * Update tests --- homeassistant/util/unit_system.py | 12 ++++++++++ tests/util/test_unit_system.py | 38 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 2a7af57776..c9da324e8a 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -277,6 +277,12 @@ METRIC_SYSTEM = UnitSystem( ("water", UnitOfVolume.CENTUM_CUBIC_FEET): UnitOfVolume.CUBIC_METERS, ("water", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS, ("water", UnitOfVolume.GALLONS): UnitOfVolume.LITERS, + # Convert wind speeds except knots to km/h + **{ + ("wind_speed", unit): UnitOfSpeed.KILOMETERS_PER_HOUR + for unit in UnitOfSpeed + if unit not in (UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS) + }, }, length=UnitOfLength.KILOMETERS, mass=UnitOfMass.GRAMS, @@ -341,6 +347,12 @@ US_CUSTOMARY_SYSTEM = UnitSystem( # Convert non-USCS volumes of water meters ("water", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, ("water", UnitOfVolume.LITERS): UnitOfVolume.GALLONS, + # Convert wind speeds except knots to mph + **{ + ("wind_speed", unit): UnitOfSpeed.MILES_PER_HOUR + for unit in UnitOfSpeed + if unit not in (UnitOfSpeed.KNOTS, UnitOfSpeed.MILES_PER_HOUR) + }, }, length=UnitOfLength.MILES, mass=UnitOfMass.POUNDS, diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index 01aa1256fd..44b287bd05 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -457,6 +457,25 @@ def test_get_unit_system_invalid(key: str) -> None: (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_METERS, None), (SensorDeviceClass.WATER, UnitOfVolume.LITERS, None), (SensorDeviceClass.WATER, "very_much", None), + # Test wind speed conversion + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.MILES_PER_HOUR, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KILOMETERS_PER_HOUR, None), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KNOTS, None), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, "very_fast", None), ), ) def test_get_metric_converted_unit_( @@ -657,6 +676,25 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_FEET, None), (SensorDeviceClass.WATER, UnitOfVolume.GALLONS, None), (SensorDeviceClass.WATER, "very_much", None), + # Test wind speed conversion + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KNOTS, None), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.MILES_PER_HOUR, None), + (SensorDeviceClass.WIND_SPEED, "very_fast", None), ), ) def test_get_us_converted_unit( From 8d21e2b168c995346c8c6af7fe077ca0e97e6ab3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Mar 2023 13:11:33 +0200 Subject: [PATCH 0336/1165] Use metric units internally in Accuweather integration (#90444) * Use metric units internally * Remove unnecessary code * Simplify sensor classes * Remove AccuWeatherForecastSensor class * Update wind speed value in test * Return suggested_unit_of_measurement for wind entities * Clean test * Use _attr_suggested_unit_of_measurement * Remove _get_suggested_unit() * Remove unnecessarey code --- .../components/accuweather/__init__.py | 7 +- homeassistant/components/accuweather/const.py | 1 - .../components/accuweather/sensor.py | 162 +++++++----------- .../components/accuweather/weather.py | 45 ++--- tests/components/accuweather/test_sensor.py | 16 +- 5 files changed, 82 insertions(+), 149 deletions(-) diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 89af284f87..4a015728d6 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -17,7 +17,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER @@ -116,11 +115,7 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async with timeout(10): current = await self.accuweather.async_get_current_conditions() forecast = ( - await self.accuweather.async_get_forecast( - metric=self.hass.config.units is METRIC_SYSTEM - ) - if self.forecast - else {} + await self.accuweather.async_get_forecast() if self.forecast else {} ) except ( ApiError, diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 1336e31f41..87bc8eaef8 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -20,7 +20,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ) -API_IMPERIAL: Final = "Imperial" API_METRIC: Final = "Metric" ATTRIBUTION: Final = "Data provided by AccuWeather" ATTR_CATEGORY: Final = "Category" diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 6cb0b45418..4d58919947 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -26,11 +26,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator from .const import ( - API_IMPERIAL, API_METRIC, ATTR_CATEGORY, ATTR_DIRECTION, @@ -51,7 +49,7 @@ PARALLEL_UPDATES = 1 class AccuWeatherSensorDescriptionMixin: """Mixin for AccuWeather sensor.""" - value_fn: Callable[[dict[str, Any], str], StateType] + value_fn: Callable[[dict[str, Any]], StateType] @dataclass @@ -61,8 +59,6 @@ class AccuWeatherSensorDescription( """Class describing AccuWeather sensor entities.""" attr_fn: Callable[[dict[str, Any]], dict[str, StateType]] = lambda _: {} - metric_unit: str | None = None - us_customary_unit: str | None = None FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( @@ -72,7 +68,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Cloud cover day", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="CloudCoverNight", @@ -80,7 +76,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Cloud cover night", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Grass", @@ -88,7 +84,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Grass pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -96,7 +92,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:weather-partly-cloudy", name="Hours of sun", native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data, _: cast(float, data), + value_fn=lambda data: cast(float, data), ), AccuWeatherSensorDescription( key="Mold", @@ -104,7 +100,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Mold pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -112,7 +108,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:vector-triangle", name="Ozone", entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -121,56 +117,52 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Ragweed pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( key="RealFeelTemperatureMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature max", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature min", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade max", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade min", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityDay", icon="mdi:weather-lightning", name="Thunderstorm probability day", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityNight", icon="mdi:weather-lightning", name="Thunderstorm probability night", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Tree", @@ -178,7 +170,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Tree pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -186,7 +178,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:weather-sunny", name="UV index", native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -194,9 +186,8 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust day", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( @@ -204,27 +195,24 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust night", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindDay", device_class=SensorDeviceClass.WIND_SPEED, name="Wind day", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindNight", device_class=SensorDeviceClass.WIND_SPEED, name="Wind night", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), ) @@ -236,9 +224,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Apparent temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Ceiling", @@ -246,9 +233,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:weather-fog", name="Cloud ceiling", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfLength.METERS, - us_customary_unit=UnitOfLength.FEET, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfLength.METERS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), suggested_display_precision=0, ), AccuWeatherSensorDescription( @@ -258,7 +244,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="DewPoint", @@ -266,18 +252,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Dew point", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperature", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShade", @@ -285,18 +269,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="RealFeel temperature shade", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Precipitation", device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, name="Precipitation", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - us_customary_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), attr_fn=lambda data: {"type": data["PrecipitationType"]}, ), AccuWeatherSensorDescription( @@ -306,7 +288,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Pressure tendency", options=["falling", "rising", "steady"], translation_key="pressure_tendency", - value_fn=lambda data, _: cast(str, data["LocalizedText"]).lower(), + value_fn=lambda data: cast(str, data["LocalizedText"]).lower(), ), AccuWeatherSensorDescription( key="UVIndex", @@ -314,7 +296,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="UV index", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]}, ), AccuWeatherSensorDescription( @@ -323,9 +305,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wet bulb temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindChillTemperature", @@ -333,18 +314,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wind chill temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Wind", device_class=SensorDeviceClass.WIND_SPEED, name="Wind", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindGust", @@ -352,9 +331,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wind gust", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), ) @@ -374,7 +352,7 @@ async def async_setup_entry( # Some air quality/allergy sensors are only available for certain # locations. sensors.extend( - AccuWeatherForecastSensor(coordinator, description, forecast_day=day) + AccuWeatherSensor(coordinator, description, forecast_day=day) for day in range(MAX_FORECAST_DAYS + 1) for description in FORECAST_SENSOR_TYPES if description.key in coordinator.data[ATTR_FORECAST][0] @@ -413,34 +391,27 @@ class AccuWeatherSensor( self._attr_unique_id = ( f"{coordinator.location_key}-{description.key}".lower() ) - self._attr_native_unit_of_measurement = description.native_unit_of_measurement - if self.coordinator.hass.config.units is METRIC_SYSTEM: - self._unit_system = API_METRIC - if metric_unit := description.metric_unit: - self._attr_native_unit_of_measurement = metric_unit - else: - self._unit_system = API_IMPERIAL - if us_customary_unit := description.us_customary_unit: - self._attr_native_unit_of_measurement = us_customary_unit self._attr_device_info = coordinator.device_info - if forecast_day is not None: - self.forecast_day = forecast_day + self.forecast_day = forecast_day @property def native_value(self) -> StateType: """Return the state.""" - return self.entity_description.value_fn(self._sensor_data, self._unit_system) + return self.entity_description.value_fn(self._sensor_data) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" + if self.forecast_day is not None: + return self.entity_description.attr_fn(self._sensor_data) + return self.entity_description.attr_fn(self.coordinator.data) @callback def _handle_coordinator_update(self) -> None: """Handle data update.""" self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key + self.coordinator.data, self.entity_description.key, self.forecast_day ) self.async_write_ha_state() @@ -458,20 +429,3 @@ def _get_sensor_data( return sensors["PrecipitationSummary"]["PastHour"] return sensors[kind] - - -class AccuWeatherForecastSensor(AccuWeatherSensor): - """Define an AccuWeather forecast entity.""" - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - return self.entity_description.attr_fn(self._sensor_data) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle data update.""" - self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key, self.forecast_day - ) - self.async_write_ha_state() diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 5c5ba303ad..76a5d62a10 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -28,17 +28,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator -from .const import ( - API_IMPERIAL, - API_METRIC, - ATTR_FORECAST, - ATTRIBUTION, - CONDITION_CLASSES, - DOMAIN, -) +from .const import API_METRIC, ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, DOMAIN PARALLEL_UPDATES = 1 @@ -66,20 +58,11 @@ class AccuWeatherEntity( # Coordinator data is used also for sensors which don't have units automatically # converted, hence the weather entity's native units follow the configured unit # system - if coordinator.hass.config.units is METRIC_SYSTEM: - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS - self._attr_native_pressure_unit = UnitOfPressure.HPA - self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_native_visibility_unit = UnitOfLength.KILOMETERS - self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR - self._unit_system = API_METRIC - else: - self._unit_system = API_IMPERIAL - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.INCHES - self._attr_native_pressure_unit = UnitOfPressure.INHG - self._attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT - self._attr_native_visibility_unit = UnitOfLength.MILES - self._attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR + self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + self._attr_native_pressure_unit = UnitOfPressure.HPA + self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_native_visibility_unit = UnitOfLength.KILOMETERS + self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR self._attr_unique_id = coordinator.location_key self._attr_attribution = ATTRIBUTION self._attr_device_info = coordinator.device_info @@ -99,16 +82,12 @@ class AccuWeatherEntity( @property def native_temperature(self) -> float: """Return the temperature.""" - return cast( - float, self.coordinator.data["Temperature"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Temperature"][API_METRIC]["Value"]) @property def native_pressure(self) -> float: """Return the pressure.""" - return cast( - float, self.coordinator.data["Pressure"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Pressure"][API_METRIC]["Value"]) @property def humidity(self) -> int: @@ -118,9 +97,7 @@ class AccuWeatherEntity( @property def native_wind_speed(self) -> float: """Return the wind speed.""" - return cast( - float, self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Wind"]["Speed"][API_METRIC]["Value"]) @property def wind_bearing(self) -> int: @@ -130,9 +107,7 @@ class AccuWeatherEntity( @property def native_visibility(self) -> float: """Return the visibility.""" - return cast( - float, self.coordinator.data["Visibility"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Visibility"][API_METRIC]["Value"]) @property def ozone(self) -> int | None: diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index e4f564f133..29f698ca52 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -741,11 +741,21 @@ async def test_sensor_imperial_units(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_cloud_ceiling") assert state - assert state.state == "10500.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog" + assert state.state == "10498.687664042" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.FEET + state = hass.states.get("sensor.home_wind") + assert state + assert state.state == "9.0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfSpeed.MILES_PER_HOUR + + state = hass.states.get("sensor.home_realfeel_temperature") + assert state + assert state.state == "77.2" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT + ) + async def test_state_update(hass: HomeAssistant) -> None: """Ensure the sensor state changes after updating the data.""" From 642984a04272ce83064e5b3fc31f200cf98872de Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 30 Mar 2023 09:14:58 -0400 Subject: [PATCH 0337/1165] Fix for is_hidden_entity when using it in select, selectattr, reject, and rejectattr (#90512) fix --- homeassistant/helpers/template.py | 15 +++++++++++---- tests/helpers/test_template.py | 5 +++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 481a59cee8..36e0a597b8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2285,9 +2285,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = pass_context(self.globals["area_devices"]) - self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) - self.tests["is_hidden_entity"] = pass_context(self.globals["is_hidden_entity"]) - self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = pass_context( self.globals["integration_entities"] @@ -2308,6 +2305,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "closest", "distance", "expand", + "is_hidden_entity", "is_state", "is_state_attr", "state_attr", @@ -2331,7 +2329,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "area_name", "has_value", ] - hass_tests = ["has_value"] + hass_tests = [ + "has_value", + "is_hidden_entity", + "is_state", + "is_state_attr", + ] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: @@ -2345,6 +2348,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["closest"] = hassfunction(closest) self.filters["closest"] = pass_context(hassfunction(closest_filter)) self.globals["distance"] = hassfunction(distance) + self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) + self.tests["is_hidden_entity"] = pass_eval_context( + self.globals["is_hidden_entity"] + ) self.globals["is_state"] = hassfunction(is_state) self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) self.globals["is_state_attr"] = hassfunction(is_state_attr) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b381775f1e..f185191d1b 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1463,6 +1463,11 @@ def test_is_hidden_entity( hass, ).async_render() + assert not template.Template( + f"{{{{ ['{visible_entity.entity_id}'] | select('is_hidden_entity') | first }}}}", + hass, + ).async_render() + def test_is_state(hass: HomeAssistant) -> None: """Test is_state method.""" From 976efb437b68afd32a275fde0630c956f029f9d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 15:16:27 +0200 Subject: [PATCH 0338/1165] Include channel in response to WS thread/list_datasets (#90493) --- .../components/thread/dataset_store.py | 10 +++++++ .../components/thread/websocket_api.py | 1 + tests/components/thread/test_dataset_store.py | 29 +++++++++++++++++++ tests/components/thread/test_websocket_api.py | 3 ++ 4 files changed, 43 insertions(+) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index ea5a16f90c..786ea55b34 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,6 +1,7 @@ """Persistently store thread datasets.""" from __future__ import annotations +from contextlib import suppress import dataclasses from datetime import datetime from functools import cached_property @@ -35,6 +36,15 @@ class DatasetEntry: created: datetime = dataclasses.field(default_factory=dt_util.utcnow) id: str = dataclasses.field(default_factory=ulid_util.ulid) + @property + def channel(self) -> int | None: + """Return channel as an integer.""" + if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: + return None + with suppress(ValueError): + return int(channel, 16) + return None + @cached_property def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]: """Return the dataset in dict format.""" diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 9f9bc3455a..aca0d5e5d9 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -144,6 +144,7 @@ async def ws_list_datasets( for dataset in store.datasets.values(): result.append( { + "channel": dataset.channel, "created": dataset.created, "dataset_id": dataset.id, "extended_pan_id": dataset.extended_pan_id, diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 581329e860..212db0de06 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -19,6 +19,18 @@ DATASET_1_REORDERED = ( "10445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F801021234" ) +DATASET_1_BAD_CHANNEL = ( + "0E080000000000010000000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_NO_CHANNEL = ( + "0E08000000000001000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + async def test_add_invalid_dataset(hass: HomeAssistant) -> None: """Test adding an invalid dataset.""" @@ -109,6 +121,8 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: {"source": "Google", "tlv": DATASET_1}, {"source": "Multipan", "tlv": DATASET_2}, {"source": "🎅", "tlv": DATASET_3}, + {"source": "test1", "tlv": DATASET_1_BAD_CHANNEL}, + {"source": "test2", "tlv": DATASET_1_NO_CHANNEL}, ] for dataset in datasets: @@ -122,25 +136,40 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: dataset_2 = dataset if dataset.source == "🎅": dataset_3 = dataset + if dataset.source == "test1": + dataset_4 = dataset + if dataset.source == "test2": + dataset_5 = dataset dataset = store.async_get(dataset_1.id) assert dataset == dataset_1 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "OpenThreadDemo" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_2.id) assert dataset == dataset_2 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "HomeAssistant!" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_3.id) assert dataset == dataset_3 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "~🐣🐥🐤~" assert dataset.pan_id == "1234" + dataset = store.async_get(dataset_4.id) + assert dataset == dataset_4 + assert dataset.channel is None + + dataset = store.async_get(dataset_5.id) + assert dataset == dataset_5 + assert dataset.channel is None + async def test_load_datasets(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index c2e9e5f593..c7bdd78188 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -153,6 +153,7 @@ async def test_list_get_dataset( assert msg["result"] == { "datasets": [ { + "channel": 15, "created": dataset_1.created.isoformat(), "dataset_id": dataset_1.id, "extended_pan_id": "1111111122222222", @@ -162,6 +163,7 @@ async def test_list_get_dataset( "source": "Google", }, { + "channel": 15, "created": dataset_2.created.isoformat(), "dataset_id": dataset_2.id, "extended_pan_id": "1111111122222222", @@ -171,6 +173,7 @@ async def test_list_get_dataset( "source": "Multipan", }, { + "channel": 15, "created": dataset_3.created.isoformat(), "dataset_id": dataset_3.id, "extended_pan_id": "1111111122222222", From 0b72cc9f5ef4494fa2d0e7e52ca21894626b21e8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:21:45 -0400 Subject: [PATCH 0339/1165] OpenAI to rely on built-in `areas` variable (#90481) --- homeassistant/components/openai_conversation/__init__.py | 3 +-- homeassistant/components/openai_conversation/const.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 3e67d4e27d..6f76142106 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import area_registry as ar, intent, template +from homeassistant.helpers import intent, template from homeassistant.util import ulid from .const import ( @@ -138,7 +138,6 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return template.Template(raw_prompt, self.hass).async_render( { "ha_name": self.hass.config.location_name, - "areas": list(ar.async_get(self.hass).areas.values()), }, parse_result=False, ) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 88289eb90b..46f8603c5f 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -5,13 +5,13 @@ CONF_PROMPT = "prompt" DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. An overview of the areas and the devices in this smart home: -{%- for area in areas %} +{%- for area in areas() %} {%- set area_info = namespace(printed=false) %} - {%- for device in area_devices(area.name) -%} + {%- for device in area_devices(area) -%} {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} {%- if not area_info.printed %} -{{ area.name }}: +{{ area_name(area) }}: {%- set area_info.printed = true %} {%- endif %} - {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} From 87c46595207ce7b4baf74adcfbce26b87e75b7f0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:23:13 -0400 Subject: [PATCH 0340/1165] Unregister webhook when registering webhook with nuki fials (#90514) --- homeassistant/components/nuki/__init__.py | 43 +++++++++++++---------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 74245d30d4..8a7985fe28 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -25,6 +25,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.network import get_url from homeassistant.helpers.update_coordinator import ( @@ -146,23 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True ) - async def _stop_nuki(_: Event): - """Stop and remove the Nuki webhook.""" - webhook.async_unregister(hass, entry.entry_id) - try: - async with async_timeout.timeout(10): - await hass.async_add_executor_job( - _remove_webhook, bridge, entry.entry_id - ) - except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err - except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) - ) - webhook_url = webhook.async_generate_path(entry.entry_id) hass_url = get_url( hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False @@ -174,9 +158,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _register_webhook, bridge, entry.entry_id, url ) except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Invalid credentials for Bridge: {err}") from err except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Error communicating with Bridge: {err}") from err + + async def _stop_nuki(_: Event): + """Stop and remove the Nuki webhook.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, bridge, entry.entry_id + ) + except InvalidCredentialsException as err: + _LOGGER.error( + "Error unregistering webhook, invalid credentials for bridge: %s", err + ) + except RequestException as err: + _LOGGER.error("Error communicating with bridge: %s", err) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) + ) coordinator = NukiCoordinator(hass, bridge, locks, openers) From cf628dbf23c3e149e2ed904553fc58b7267ab8cb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Mar 2023 16:38:35 +0200 Subject: [PATCH 0341/1165] Add a device to the sun (#90517) --- homeassistant/components/sun/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 527ccc4069..8a253566e2 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -15,6 +15,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -126,6 +128,12 @@ class SunSensor(SensorEntity): self._attr_unique_id = f"{entry_id}-{entity_description.key}" self.sun = sun + self._attr_device_info = DeviceInfo( + name="Sun", + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" From e877fd6682ced68742a701598e3f0c129ef16f26 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Wed, 29 Mar 2023 23:43:54 +0200 Subject: [PATCH 0342/1165] Use auth token in Ezviz (#54663) * Initial commit * Revert "Initial commit" This reverts commit 452027f1a3c1be186cedd4115cea6928917c9467. * Change ezviz to token auth * Bump API version. * Add fix for token expired. Fix options update and unload. * Fix tests (PLATFORM to PLATFORM_BY_TYPE) * Uses and stores token only, added reauth step when token expires. * Add tests MFA code exceptions. * Fix tests. * Remove redundant try/except blocks. * Rebase fixes. * Fix errors in reauth config flow * Implement recommendations * Fix typing error in config_flow * Fix tests after rebase, readd camera check on init * Change to platform setup * Cleanup init. * Test for MFA required under user form * Remove useless if block. * Fix formating after rebase * Fix formating. * No longer stored in the repository --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/ezviz/__init__.py | 137 +++++---- homeassistant/components/ezviz/camera.py | 14 +- homeassistant/components/ezviz/config_flow.py | 286 +++++++++++------- homeassistant/components/ezviz/const.py | 5 +- homeassistant/components/ezviz/coordinator.py | 20 +- homeassistant/components/ezviz/strings.json | 14 +- tests/components/ezviz/__init__.py | 26 +- tests/components/ezviz/conftest.py | 8 +- tests/components/ezviz/test_config_flow.py | 242 +++++++++++++-- 9 files changed, 535 insertions(+), 217 deletions(-) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index fbd49102f3..489ff97eb4 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -2,26 +2,26 @@ import logging from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_TYPE, - CONF_URL, - CONF_USERNAME, - Platform, -) +from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DATA_COORDINATOR, - DATA_UNDO_UPDATE_LISTENER, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -30,17 +30,22 @@ from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CAMERA, - Platform.SENSOR, - Platform.SWITCH, -] +PLATFORMS_BY_TYPE: dict[str, list] = { + ATTR_TYPE_CAMERA: [], + ATTR_TYPE_CLOUD: [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.SENSOR, + Platform.SWITCH, + ], +} async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up EZVIZ from a config entry.""" hass.data.setdefault(DOMAIN, {}) + sensor_type: str = entry.data[CONF_TYPE] + ezviz_client = None if not entry.options: options = { @@ -50,69 +55,71 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, options=options) - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - if hass.data.get(DOMAIN): - # Should only execute on addition of new camera entry. - # Fetch Entry id of main account and reload it. - for item in hass.config_entries.async_entries(): - if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - _LOGGER.info("Reload EZVIZ integration with new camera rtsp entry") - await hass.config_entries.async_reload(item.entry_id) + # Initialize EZVIZ cloud entities + if PLATFORMS_BY_TYPE[sensor_type]: + # Initiate reauth config flow if account token if not present. + if not entry.data.get(CONF_SESSION_ID): + raise ConfigEntryAuthFailed - return True - - try: - ezviz_client = await hass.async_add_executor_job( - _get_ezviz_client_instance, entry + ezviz_client = EzvizClient( + token={ + CONF_SESSION_ID: entry.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: entry.data.get(CONF_RFSESSION_ID), + "api_url": entry.data.get(CONF_URL), + }, + timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - except (InvalidURL, HTTPError, PyEzvizError) as error: - _LOGGER.error("Unable to connect to EZVIZ service: %s", str(error)) - raise ConfigEntryNotReady from error - coordinator = EzvizDataUpdateCoordinator( - hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + try: + await hass.async_add_executor_job(ezviz_client.login) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error + + except (InvalidURL, HTTPError, PyEzvizError) as error: + _LOGGER.error("Unable to connect to Ezviz service: %s", str(error)) + raise ConfigEntryNotReady from error + + coordinator = EzvizDataUpdateCoordinator( + hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. + # Cameras are accessed via local RTSP stream with unique credentials per camera. + # Separate camera entities allow for credential changes per camera. + if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]: + for item in hass.config_entries.async_entries(domain=DOMAIN): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + _LOGGER.info("Reload Ezviz main account with camera entry") + await hass.config_entries.async_reload(item.entry_id) + return True + + await hass.config_entries.async_forward_entry_setups( + entry, PLATFORMS_BY_TYPE[sensor_type] ) - await coordinator.async_refresh() - - if not coordinator.last_update_success: - raise ConfigEntryNotReady - - undo_listener = entry.add_update_listener(_async_update_listener) - - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, - } - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + sensor_type = entry.data[CONF_TYPE] - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - return True - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() + unload_ok = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS_BY_TYPE[sensor_type] + ) + if sensor_type == ATTR_TYPE_CLOUD and unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -def _get_ezviz_client_instance(entry: ConfigEntry) -> EzvizClient: - """Initialize a new instance of EzvizClientApi.""" - ezviz_client = EzvizClient( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_URL], - entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ) - ezviz_client.login() - return ezviz_client diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 7901061c02..0456e7ade9 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -34,7 +34,6 @@ from .const import ( DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_RTSP_PORT, DIR_DOWN, DIR_LEFT, DIR_RIGHT, @@ -70,24 +69,17 @@ async def async_setup_entry( if item.unique_id == camera and item.source != SOURCE_IGNORE ] - # There seem to be a bug related to localRtspPort in EZVIZ API. - local_rtsp_port = ( - value["local_rtsp_port"] - if value["local_rtsp_port"] != 0 - else DEFAULT_RTSP_PORT - ) - if camera_rtsp_entry: ffmpeg_arguments = camera_rtsp_entry[0].options[CONF_FFMPEG_ARGUMENTS] camera_username = camera_rtsp_entry[0].data[CONF_USERNAME] camera_password = camera_rtsp_entry[0].data[CONF_PASSWORD] - camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{local_rtsp_port}{ffmpeg_arguments}" + camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{value['local_rtsp_port']}{ffmpeg_arguments}" _LOGGER.debug( "Configuring Camera %s with ip: %s rtsp port: %s ffmpeg arguments: %s", camera, value["local_ip"], - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) @@ -123,7 +115,7 @@ async def async_setup_entry( camera_username, camera_password, camera_rtsp_stream, - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) ) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 4c8b1418fa..77598ad6a1 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -1,12 +1,14 @@ -"""Config flow for ezviz.""" +"""Config flow for EZVIZ.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from pyezviz.client import EzvizClient from pyezviz.exceptions import ( AuthTestResultFailed, - HTTPError, + EzvizAuthVerificationCode, InvalidHost, InvalidURL, PyEzvizError, @@ -25,12 +27,15 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, @@ -40,23 +45,37 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +DEFAULT_OPTIONS = { + CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, + CONF_TIMEOUT: DEFAULT_TIMEOUT, +} -def _get_ezviz_client_instance(data): - """Initialize a new instance of EzvizClientApi.""" +def _validate_and_create_auth(data: dict) -> dict[str, Any]: + """Try to login to EZVIZ cloud account and return token.""" + # Verify cloud credentials by attempting a login request with username and password. + # Return login token. ezviz_client = EzvizClient( data[CONF_USERNAME], data[CONF_PASSWORD], - data.get(CONF_URL, EU_URL), + data[CONF_URL], data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - ezviz_client.login() - return ezviz_client + ezviz_token = ezviz_client.login() + + auth_data = { + CONF_SESSION_ID: ezviz_token[CONF_SESSION_ID], + CONF_RFSESSION_ID: ezviz_token[CONF_RFSESSION_ID], + CONF_URL: ezviz_token["api_url"], + CONF_TYPE: ATTR_TYPE_CLOUD, + } + + return auth_data -def _test_camera_rtsp_creds(data): +def _test_camera_rtsp_creds(data: dict) -> None: """Try DESCRIBE on RTSP camera with credentials.""" test_rtsp = TestRTSPAuth( @@ -71,89 +90,43 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _validate_and_create_auth(self, data): - """Try to login to ezviz cloud account and create entry if successful.""" - await self.async_set_unique_id(data[CONF_USERNAME]) - self._abort_if_unique_id_configured() - - # Verify cloud credentials by attempting a login request. - try: - await self.hass.async_add_executor_job(_get_ezviz_client_instance, data) - - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - auth_data = { - CONF_USERNAME: data[CONF_USERNAME], - CONF_PASSWORD: data[CONF_PASSWORD], - CONF_URL: data.get(CONF_URL, EU_URL), - CONF_TYPE: ATTR_TYPE_CLOUD, - } - - return self.async_create_entry(title=data[CONF_USERNAME], data=auth_data) - - async def _validate_and_create_camera_rtsp(self, data): + async def _validate_and_create_camera_rtsp(self, data: dict) -> FlowResult: """Try DESCRIBE on RTSP camera with credentials.""" # Get EZVIZ cloud credentials from config entry - ezviz_client_creds = { - CONF_USERNAME: None, - CONF_PASSWORD: None, - CONF_URL: None, + ezviz_token = { + CONF_SESSION_ID: None, + CONF_RFSESSION_ID: None, + "api_url": None, } + ezviz_timeout = DEFAULT_TIMEOUT for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - ezviz_client_creds = { - CONF_USERNAME: item.data.get(CONF_USERNAME), - CONF_PASSWORD: item.data.get(CONF_PASSWORD), - CONF_URL: item.data.get(CONF_URL), + ezviz_token = { + CONF_SESSION_ID: item.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: item.data.get(CONF_RFSESSION_ID), + "api_url": item.data.get(CONF_URL), } + ezviz_timeout = item.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) # Abort flow if user removed cloud account before adding camera. - if ezviz_client_creds[CONF_USERNAME] is None: + if ezviz_token.get(CONF_SESSION_ID) is None: return self.async_abort(reason="ezviz_cloud_account_missing") + ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout) + # We need to wake hibernating cameras. # First create EZVIZ API instance. - try: - ezviz_client = await self.hass.async_add_executor_job( - _get_ezviz_client_instance, ezviz_client_creds - ) + await self.hass.async_add_executor_job(ezviz_client.login) - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - # Secondly try to wake hibernating camera. - try: - await self.hass.async_add_executor_job( - ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] - ) - - except HTTPError as err: - raise InvalidHost from err + # Secondly try to wake hybernating camera. + await self.hass.async_add_executor_job( + ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] + ) # Thirdly attempts an authenticated RTSP DESCRIBE request. - try: - await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) - - except InvalidHost as err: - raise InvalidHost from err - - except AuthTestResultFailed as err: - raise AuthTestResultFailed from err + await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) return self.async_create_entry( title=data[ATTR_SERIAL], @@ -162,6 +135,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): CONF_PASSWORD: data[CONF_PASSWORD], CONF_TYPE: ATTR_TYPE_CAMERA, }, + options=DEFAULT_OPTIONS, ) @staticmethod @@ -170,18 +144,24 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return EzvizOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" - # Check if ezviz cloud account is present in entry config, + # Check if EZVIZ cloud account is present in entry config, # abort if already configured. for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: return self.async_abort(reason="already_configured_account") errors = {} + auth_data = {} if user_input is not None: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + if user_input[CONF_URL] == CONF_CUSTOMIZE: self.context["data"] = { CONF_USERNAME: user_input[CONF_USERNAME], @@ -189,11 +169,10 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): } return await self.async_step_user_custom_url() - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -201,6 +180,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -208,6 +190,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -222,20 +211,21 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) - async def async_step_user_custom_url(self, user_input=None): + async def async_step_user_custom_url( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user for custom region url.""" - errors = {} + auth_data = {} if user_input is not None: user_input[CONF_USERNAME] = self.context["data"][CONF_USERNAME] user_input[CONF_PASSWORD] = self.context["data"][CONF_PASSWORD] - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -243,6 +233,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -250,6 +243,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema_custom_url = vol.Schema( { vol.Required(CONF_URL, default=EU_URL): str, @@ -260,18 +260,22 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user_custom_url", data_schema=data_schema_custom_url, errors=errors ) - async def async_step_integration_discovery(self, discovery_info): + async def async_step_integration_discovery( + self, discovery_info: dict[str, Any] + ) -> FlowResult: """Handle a flow for discovered camera without rtsp config entry.""" await self.async_set_unique_id(discovery_info[ATTR_SERIAL]) self._abort_if_unique_id_configured() - self.context["title_placeholders"] = {"serial": self.unique_id} + self.context["title_placeholders"] = {ATTR_SERIAL: self.unique_id} self.context["data"] = {CONF_IP_ADDRESS: discovery_info[CONF_IP_ADDRESS]} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm and create entry from discovery step.""" errors = {} @@ -284,6 +288,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except (InvalidHost, InvalidURL): errors["base"] = "invalid_host" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except (PyEzvizError, AuthTestResultFailed): errors["base"] = "invalid_auth" @@ -303,11 +310,76 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=discovered_camera_schema, errors=errors, description_placeholders={ - "serial": self.unique_id, + ATTR_SERIAL: self.unique_id, CONF_IP_ADDRESS: self.context["data"][CONF_IP_ADDRESS], }, ) + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle a flow for reauthentication with password.""" + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a Confirm flow for reauthentication with password.""" + auth_data = {} + errors = {} + entry = None + + for item in self._async_current_entries(): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + self.context["title_placeholders"] = {ATTR_SERIAL: item.title} + entry = await self.async_set_unique_id(item.title) + + if not entry: + return self.async_abort(reason="ezviz_cloud_account_missing") + + if user_input is not None: + user_input[CONF_URL] = entry.data[CONF_URL] + + try: + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) + + except (InvalidHost, InvalidURL): + errors["base"] = "invalid_host" + + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + + except (PyEzvizError, AuthTestResultFailed): + errors["base"] = "invalid_auth" + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + else: + self.hass.config_entries.async_update_entry( + entry, + data=auth_data, + ) + + await self.hass.config_entries.async_reload(entry.entry_id) + + return self.async_abort(reason="reauth_successful") + + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME, default=entry.title): vol.In([entry.title]), + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + errors=errors, + ) + class EzvizOptionsFlowHandler(OptionsFlow): """Handle EZVIZ client options.""" @@ -316,22 +388,28 @@ class EzvizOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage EZVIZ options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) - options = { - vol.Optional( - CONF_TIMEOUT, - default=self.config_entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ): int, - vol.Optional( - CONF_FFMPEG_ARGUMENTS, - default=self.config_entry.options.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ), - ): str, - } + options = vol.Schema( + { + vol.Optional( + CONF_TIMEOUT, + default=self.config_entry.options.get( + CONF_TIMEOUT, DEFAULT_TIMEOUT + ), + ): int, + vol.Optional( + CONF_FFMPEG_ARGUMENTS, + default=self.config_entry.options.get( + CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS + ), + ): str, + } + ) - return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + return self.async_show_form(step_id="init", data_schema=options) diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index b9183772b6..d052a4b821 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -10,6 +10,9 @@ ATTR_HOME = "HOME_MODE" ATTR_AWAY = "AWAY_MODE" ATTR_TYPE_CLOUD = "EZVIZ_CLOUD_ACCOUNT" ATTR_TYPE_CAMERA = "CAMERA_ACCOUNT" +CONF_SESSION_ID = "session_id" +CONF_RFSESSION_ID = "rf_session_id" +CONF_EZVIZ_ACCOUNT = "ezviz_account" # Services data DIR_UP = "up" @@ -33,10 +36,8 @@ SERVICE_DETECTION_SENSITIVITY = "set_alarm_detection_sensibility" EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" -DEFAULT_RTSP_PORT = 554 DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" # Data DATA_COORDINATOR = "coordinator" -DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index cc4537bb9b..ba8ed336a5 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -4,9 +4,16 @@ import logging from async_timeout import timeout from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -27,15 +34,16 @@ class EzvizDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - def _update_data(self) -> dict: - """Fetch data from EZVIZ via camera load function.""" - return self.ezviz_client.load_cameras() - async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" try: async with timeout(self._api_timeout): - return await self.hass.async_add_executor_job(self._update_data) + return await self.hass.async_add_executor_job( + self.ezviz_client.load_cameras + ) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error except (InvalidURL, HTTPError, PyEzvizError) as error: raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 91fa32ad9b..5e258e4270 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -26,17 +26,27 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Enter credentials to reauthenticate to ezviz cloud account", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "mfa_required": "2FA enabled on account, please disable and retry" }, "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account" + "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/ezviz/__init__.py b/tests/components/ezviz/__init__.py index 64dcbfc26e..768fc30cc8 100644 --- a/tests/components/ezviz/__init__.py +++ b/tests/components/ezviz/__init__.py @@ -3,8 +3,11 @@ from unittest.mock import patch from homeassistant.components.ezviz.const import ( ATTR_SERIAL, + ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -22,8 +25,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry ENTRY_CONFIG = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_SESSION_ID: "test-username", + CONF_RFSESSION_ID: "test-password", CONF_URL: "apiieu.ezvizlife.com", CONF_TYPE: ATTR_TYPE_CLOUD, } @@ -46,6 +49,18 @@ USER_INPUT = { CONF_TYPE: ATTR_TYPE_CLOUD, } +USER_INPUT_CAMERA_VALIDATE = { + ATTR_SERIAL: "C666666", + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", +} + +USER_INPUT_CAMERA = { + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", + CONF_TYPE: ATTR_TYPE_CAMERA, +} + DISCOVERY_INFO = { ATTR_SERIAL: "C666666", CONF_USERNAME: None, @@ -59,6 +74,13 @@ TEST = { CONF_IP_ADDRESS: "127.0.0.1", } +API_LOGIN_RETURN_VALIDATE = { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, +} + def _patch_async_setup_entry(return_value=True): return patch( diff --git a/tests/components/ezviz/conftest.py b/tests/components/ezviz/conftest.py index 76b962250b..e89e375fb5 100644 --- a/tests/components/ezviz/conftest.py +++ b/tests/components/ezviz/conftest.py @@ -5,6 +5,12 @@ from pyezviz import EzvizClient from pyezviz.test_cam_rtsp import TestRTSPAuth import pytest +ezviz_login_token_return = { + "session_id": "fake_token", + "rf_session_id": "fake_rf_token", + "api_url": "apiieu.ezvizlife.com", +} + @pytest.fixture(autouse=True) def mock_ffmpeg(hass): @@ -42,7 +48,7 @@ def ezviz_config_flow(hass): "1", ) - instance.login = MagicMock(return_value=True) + instance.login = MagicMock(return_value=ezviz_login_token_return) instance.get_detection_sensibility = MagicMock(return_value=True) yield mock_ezviz diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 624827220c..939bb92bcc 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from pyezviz.exceptions import ( AuthTestResultFailed, + EzvizAuthVerificationCode, HTTPError, InvalidHost, InvalidURL, @@ -12,13 +13,16 @@ from pyezviz.exceptions import ( from homeassistant.components.ezviz.const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, - ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, ) -from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_INTEGRATION_DISCOVERY, + SOURCE_REAUTH, + SOURCE_USER, +) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -32,8 +36,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + API_LOGIN_RETURN_VALIDATE, DISCOVERY_INFO, - USER_INPUT, USER_INPUT_VALIDATE, _patch_async_setup_entry, init_integration, @@ -59,7 +63,7 @@ async def test_user_form(hass: HomeAssistant, ezviz_config_flow) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" - assert result["data"] == {**USER_INPUT} + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} assert len(mock_setup_entry.mock_calls) == 1 @@ -78,7 +82,11 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass", CONF_URL: "customize"}, + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: CONF_CUSTOMIZE, + }, ) assert result["type"] == FlowResultType.FORM @@ -90,21 +98,58 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result["flow_id"], {CONF_URL: "test-user"}, ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_PASSWORD: "test-pass", - CONF_TYPE: ATTR_TYPE_CLOUD, - CONF_URL: "test-user", - CONF_USERNAME: "test-user", - } + assert result["data"] == API_LOGIN_RETURN_VALIDATE assert len(mock_setup_entry.mock_calls) == 1 -async def test_step_discovery_abort_if_cloud_account_missing( - hass: HomeAssistant, -) -> None: +async def test_async_step_reauth(hass, ezviz_config_flow): + """Test the reauth step.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_step_discovery_abort_if_cloud_account_missing(hass): """Test discovery and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( @@ -127,11 +172,21 @@ async def test_step_discovery_abort_if_cloud_account_missing( assert result["reason"] == "ezviz_cloud_account_missing" +async def test_step_reauth_abort_if_cloud_account_missing(hass): + """Test reauth and confirm step, abort if cloud account was removed.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "ezviz_cloud_account_missing" + + async def test_async_step_integration_discovery( - hass: HomeAssistant, ezviz_config_flow, ezviz_test_rtsp_config_flow -) -> None: + hass, ezviz_config_flow, ezviz_test_rtsp_config_flow +): """Test discovery and confirm step.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -189,11 +244,14 @@ async def test_options_flow(hass: HomeAssistant) -> None: async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> None: """Test we handle exception on user form.""" - ezviz_config_flow.side_effect = PyEzvizError - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + ezviz_config_flow.side_effect = PyEzvizError result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -215,6 +273,17 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["step_id"] == "user" assert result["errors"] == {"base": "invalid_host"} + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "mfa_required"} + ezviz_config_flow.side_effect = HTTPError result = await hass.config_entries.flow.async_configure( @@ -224,7 +293,7 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} ezviz_config_flow.side_effect = Exception @@ -242,7 +311,7 @@ async def test_discover_exception_step1( ezviz_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -295,7 +364,21 @@ async def test_discover_exception_step1( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-pass", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -317,7 +400,7 @@ async def test_discover_exception_step3( ezviz_test_rtsp_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -423,7 +506,18 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user_custom_url" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -434,3 +528,103 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "unknown" + + +async def test_async_step_reauth_exception(hass, ezviz_config_flow): + """Test the reauth step exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + ezviz_config_flow.side_effect = InvalidURL() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = InvalidHost() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "mfa_required"} + + ezviz_config_flow.side_effect = PyEzvizError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = Exception() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" From 9d116799d603ffaab8b2942c3b9cd915c181f198 Mon Sep 17 00:00:00 2001 From: Thijs W Date: Thu, 30 Mar 2023 08:05:24 +0200 Subject: [PATCH 0343/1165] Add missing strings in frontier_silicon (#90446) Improve confirm message for ssdp flow --- homeassistant/components/frontier_silicon/config_flow.py | 4 +++- homeassistant/components/frontier_silicon/strings.json | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index a054bd2b30..0ccc61e99c 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -188,7 +188,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self._async_create_entry() self._set_confirm_only() - return self.async_show_form(step_id="confirm") + return self.async_show_form( + step_id="confirm", description_placeholders={"name": self._name} + ) async def async_step_device_config( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 3a0a504761..a7c3f3e439 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -13,6 +13,9 @@ "data": { "pin": "[%key:common::config_flow::data::pin%]" } + }, + "confirm": { + "description": "Do you want to set up {name}?" } }, "error": { From baccbd98c7257216d7082e54be451727dac32f7a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Mar 2023 23:26:05 +0200 Subject: [PATCH 0344/1165] Bump reolink-aio to 0.5.8 (#90467) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 95b180fc16..79fc15c571 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.7"] + "requirements": ["reolink-aio==0.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index d51947b81c..22e80fced4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 514e653d34..fa7b257f8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 From b5811ad1c2fa31d05111ebc0fbcba8c6b257e331 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 29 Mar 2023 23:25:33 +0200 Subject: [PATCH 0345/1165] Add entity name translations for devolo Home Network (#90471) --- .../devolo_home_network/binary_sensor.py | 1 - .../components/devolo_home_network/entity.py | 1 + .../components/devolo_home_network/sensor.py | 3 --- .../devolo_home_network/strings.json | 26 +++++++++++++++++++ .../components/devolo_home_network/switch.py | 2 -- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index e927ea9333..809dc9086b 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -53,7 +53,6 @@ SENSOR_TYPES: dict[str, DevoloBinarySensorEntityDescription] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:router-network", - name="Connected to router", value_func=_is_connected_to_router, ), } diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a26d8dce8f..8b665d7bf0 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -57,4 +57,5 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): name=entry.title, sw_version=device.firmware_version, ) + self._attr_translation_key = self.entity_description.key self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 2c2637c2f8..aeeab2ce89 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -54,7 +54,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lan", - name="Connected PLC devices", value_func=lambda data: len( {device.mac_address_from for device in data.data_rates} ), @@ -62,7 +61,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[list[ConnectedStationInfo]]( key=CONNECTED_WIFI_CLIENTS, icon="mdi:wifi", - name="Connected Wifi clients", state_class=SensorStateClass.MEASUREMENT, value_func=len, ), @@ -71,7 +69,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:wifi-marker", - name="Neighboring Wifi networks", value_func=len, ), } diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 6c320710a1..3472886cd5 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -27,5 +27,31 @@ "home_control": "The devolo Home Control Central Unit does not work with this integration.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "connected_to_router": { + "name": "Connected to router" + } + }, + "sensor": { + "connected_plc_devices": { + "name": "Connected PLC devices" + }, + "connected_wifi_clients": { + "name": "Connected Wifi clients" + }, + "neighboring_wifi_networks": { + "name": "Neighboring Wifi networks" + } + }, + "switch": { + "switch_guest_wifi": { + "name": "Enable guest Wifi" + }, + "switch_leds": { + "name": "Enable LEDs" + } + } } } diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index fa2447985d..6f387fdf05 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -42,7 +42,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { SWITCH_GUEST_WIFI: DevoloSwitchEntityDescription[WifiGuestAccessGet]( key=SWITCH_GUEST_WIFI, icon="mdi:wifi", - name="Enable guest Wifi", is_on_func=lambda data: data.enabled is True, turn_on_func=lambda device: device.device.async_set_wifi_guest_access(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_wifi_guest_access(False), # type: ignore[union-attr] @@ -51,7 +50,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { key=SWITCH_LEDS, entity_category=EntityCategory.CONFIG, icon="mdi:led-off", - name="Enable LEDs", is_on_func=bool, turn_on_func=lambda device: device.device.async_set_led_setting(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_led_setting(False), # type: ignore[union-attr] From 9f3c0fa9271c1e405d850c18d0332da27f55c6fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:24:47 -1000 Subject: [PATCH 0346/1165] Bump yalexs-ble to 2.1.14 (#90474) changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.13...v2.1.14 reduces ble traffic (fixes a bug were we were checking when we did not need to be) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 07ecc2a1be..84b5ae7e20 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.13"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.14"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 7c45f309e6..f1ec6ba14c 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.13"] + "requirements": ["yalexs-ble==2.1.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22e80fced4..cae40bd2c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa7b257f8e..75b7dfeb3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1911,7 +1911,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 From 02f108498cf729e944038affa1a9205243853df9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Mar 2023 10:21:11 +0200 Subject: [PATCH 0347/1165] Add missing strings to sensor integration (#90475) * Add missing strings to sensor integration * Enumeration * Apply suggestion Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/sensor/strings.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 5b34c5a28e..16e0da0d51 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -160,6 +160,9 @@ "energy_storage": { "name": "Stored energy" }, + "enum": { + "name": "[%key:component::sensor::title%]" + }, "frequency": { "name": "Frequency" }, @@ -235,6 +238,9 @@ "temperature": { "name": "Temperature" }, + "timestamp": { + "name": "Timestamp" + }, "volatile_organic_compounds": { "name": "VOCs" }, From 30af4c769e723fda0f7511e34a88a3ab0bd7ba59 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:24:26 -0400 Subject: [PATCH 0348/1165] Correctly load ZHA settings from API when integration is not running (#90476) Correctly load settings from the zigpy database when ZHA is not running --- homeassistant/components/zha/api.py | 23 ++++++++--------------- tests/components/zha/test_api.py | 5 ++++- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index d34dd2338e..652f19d24b 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -18,8 +18,6 @@ from .core.const import ( from .core.gateway import ZHAGateway if TYPE_CHECKING: - from zigpy.application import ControllerApplication - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -49,21 +47,17 @@ def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: return entries[0] -def _wrap_network_settings(app: ControllerApplication) -> NetworkBackup: - """Wrap the ZHA network settings into a `NetworkBackup`.""" +def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: + """Get the network settings for the currently active ZHA network.""" + zha_gateway: ZHAGateway = _get_gateway(hass) + app = zha_gateway.application_controller + return NetworkBackup( node_info=app.state.node_info, network_info=app.state.network_info, ) -def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: - """Get the network settings for the currently active ZHA network.""" - zha_gateway: ZHAGateway = _get_gateway(hass) - - return _wrap_network_settings(zha_gateway.application_controller) - - async def async_get_last_network_settings( hass: HomeAssistant, config_entry: ConfigEntry | None = None ) -> NetworkBackup | None: @@ -79,13 +73,12 @@ async def async_get_last_network_settings( try: await app._load_db() # pylint: disable=protected-access - settings = _wrap_network_settings(app) + settings = max(app.backups, key=lambda b: b.backup_time) + except ValueError: + settings = None finally: await app.shutdown() - if settings.network_info.channel == 0: - return None - return settings diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index c607909980..59daf2179b 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +import zigpy.backups import zigpy.state from homeassistant.components import zha @@ -36,7 +37,9 @@ async def test_async_get_network_settings_inactive( gateway = api._get_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) - zigpy_app_controller.state.network_info.channel = 20 + backup = zigpy.backups.NetworkBackup() + backup.network_info.channel = 20 + zigpy_app_controller.backups.backups.append(backup) with patch( "bellows.zigbee.application.ControllerApplication.__new__", From 2a627e63f1c5ac837f53a052278e6df90a0620ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:26:28 -1000 Subject: [PATCH 0349/1165] Fix filesize doing blocking I/O in the event loop (#90479) Fix filesize doing I/O in the event loop --- homeassistant/components/filesize/__init__.py | 19 +++++++------------ .../components/filesize/config_flow.py | 4 +++- homeassistant/core.py | 6 +++++- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 9e08615d4a..73f060e79b 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -11,24 +11,19 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import PLATFORMS -def check_path(path: pathlib.Path) -> bool: - """Check path.""" - return path.exists() and path.is_file() - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up from a config entry.""" - - path = entry.data[CONF_FILE_PATH] +def _check_path(hass: HomeAssistant, path: str) -> None: + """Check if path is valid and allowed.""" get_path = pathlib.Path(path) - - check_file = await hass.async_add_executor_job(check_path, get_path) - if not check_file: + if not get_path.exists() or not get_path.is_file(): raise ConfigEntryNotReady(f"Can not access file {path}") if not hass.config.is_allowed_path(path): raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed") + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH]) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/config_flow.py b/homeassistant/components/filesize/config_flow.py index 3f58e636b0..8633e6ec46 100644 --- a/homeassistant/components/filesize/config_flow.py +++ b/homeassistant/components/filesize/config_flow.py @@ -49,7 +49,9 @@ class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - full_path = validate_path(self.hass, user_input[CONF_FILE_PATH]) + full_path = await self.hass.async_add_executor_job( + validate_path, self.hass, user_input[CONF_FILE_PATH] + ) except NotValidError: errors["base"] = "not_valid" except NotAllowedError: diff --git a/homeassistant/core.py b/homeassistant/core.py index 900355d4a5..78ceb620e5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1950,7 +1950,11 @@ class Config: ) def is_allowed_path(self, path: str) -> bool: - """Check if the path is valid for access from outside.""" + """Check if the path is valid for access from outside. + + This function does blocking I/O and should not be called from the event loop. + Use hass.async_add_executor_job to schedule it on the executor. + """ assert path is not None thepath = pathlib.Path(path) From b83cb5d1b1c3be759e84f08f8faa3ac5ce1f8ccd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:21:45 -0400 Subject: [PATCH 0350/1165] OpenAI to rely on built-in `areas` variable (#90481) --- homeassistant/components/openai_conversation/__init__.py | 3 +-- homeassistant/components/openai_conversation/const.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 3e67d4e27d..6f76142106 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import area_registry as ar, intent, template +from homeassistant.helpers import intent, template from homeassistant.util import ulid from .const import ( @@ -138,7 +138,6 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return template.Template(raw_prompt, self.hass).async_render( { "ha_name": self.hass.config.location_name, - "areas": list(ar.async_get(self.hass).areas.values()), }, parse_result=False, ) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 88289eb90b..46f8603c5f 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -5,13 +5,13 @@ CONF_PROMPT = "prompt" DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. An overview of the areas and the devices in this smart home: -{%- for area in areas %} +{%- for area in areas() %} {%- set area_info = namespace(printed=false) %} - {%- for device in area_devices(area.name) -%} + {%- for device in area_devices(area) -%} {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} {%- if not area_info.printed %} -{{ area.name }}: +{{ area_name(area) }}: {%- set area_info.printed = true %} {%- endif %} - {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} From 2157a4d0fcb95ea43c688b84db0a7aa8335a149c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 15:16:27 +0200 Subject: [PATCH 0351/1165] Include channel in response to WS thread/list_datasets (#90493) --- .../components/thread/dataset_store.py | 10 +++++++ .../components/thread/websocket_api.py | 1 + tests/components/thread/test_dataset_store.py | 29 +++++++++++++++++++ tests/components/thread/test_websocket_api.py | 3 ++ 4 files changed, 43 insertions(+) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index ea5a16f90c..786ea55b34 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,6 +1,7 @@ """Persistently store thread datasets.""" from __future__ import annotations +from contextlib import suppress import dataclasses from datetime import datetime from functools import cached_property @@ -35,6 +36,15 @@ class DatasetEntry: created: datetime = dataclasses.field(default_factory=dt_util.utcnow) id: str = dataclasses.field(default_factory=ulid_util.ulid) + @property + def channel(self) -> int | None: + """Return channel as an integer.""" + if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: + return None + with suppress(ValueError): + return int(channel, 16) + return None + @cached_property def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]: """Return the dataset in dict format.""" diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 9f9bc3455a..aca0d5e5d9 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -144,6 +144,7 @@ async def ws_list_datasets( for dataset in store.datasets.values(): result.append( { + "channel": dataset.channel, "created": dataset.created, "dataset_id": dataset.id, "extended_pan_id": dataset.extended_pan_id, diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 581329e860..212db0de06 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -19,6 +19,18 @@ DATASET_1_REORDERED = ( "10445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F801021234" ) +DATASET_1_BAD_CHANNEL = ( + "0E080000000000010000000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_NO_CHANNEL = ( + "0E08000000000001000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + async def test_add_invalid_dataset(hass: HomeAssistant) -> None: """Test adding an invalid dataset.""" @@ -109,6 +121,8 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: {"source": "Google", "tlv": DATASET_1}, {"source": "Multipan", "tlv": DATASET_2}, {"source": "🎅", "tlv": DATASET_3}, + {"source": "test1", "tlv": DATASET_1_BAD_CHANNEL}, + {"source": "test2", "tlv": DATASET_1_NO_CHANNEL}, ] for dataset in datasets: @@ -122,25 +136,40 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: dataset_2 = dataset if dataset.source == "🎅": dataset_3 = dataset + if dataset.source == "test1": + dataset_4 = dataset + if dataset.source == "test2": + dataset_5 = dataset dataset = store.async_get(dataset_1.id) assert dataset == dataset_1 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "OpenThreadDemo" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_2.id) assert dataset == dataset_2 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "HomeAssistant!" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_3.id) assert dataset == dataset_3 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "~🐣🐥🐤~" assert dataset.pan_id == "1234" + dataset = store.async_get(dataset_4.id) + assert dataset == dataset_4 + assert dataset.channel is None + + dataset = store.async_get(dataset_5.id) + assert dataset == dataset_5 + assert dataset.channel is None + async def test_load_datasets(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index c2e9e5f593..c7bdd78188 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -153,6 +153,7 @@ async def test_list_get_dataset( assert msg["result"] == { "datasets": [ { + "channel": 15, "created": dataset_1.created.isoformat(), "dataset_id": dataset_1.id, "extended_pan_id": "1111111122222222", @@ -162,6 +163,7 @@ async def test_list_get_dataset( "source": "Google", }, { + "channel": 15, "created": dataset_2.created.isoformat(), "dataset_id": dataset_2.id, "extended_pan_id": "1111111122222222", @@ -171,6 +173,7 @@ async def test_list_get_dataset( "source": "Multipan", }, { + "channel": 15, "created": dataset_3.created.isoformat(), "dataset_id": dataset_3.id, "extended_pan_id": "1111111122222222", From 01734c0dab46a55f0bd6f001320fb08077c1b36a Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 30 Mar 2023 09:14:58 -0400 Subject: [PATCH 0352/1165] Fix for is_hidden_entity when using it in select, selectattr, reject, and rejectattr (#90512) fix --- homeassistant/helpers/template.py | 15 +++++++++++---- tests/helpers/test_template.py | 5 +++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 481a59cee8..36e0a597b8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2285,9 +2285,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = pass_context(self.globals["area_devices"]) - self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) - self.tests["is_hidden_entity"] = pass_context(self.globals["is_hidden_entity"]) - self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = pass_context( self.globals["integration_entities"] @@ -2308,6 +2305,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "closest", "distance", "expand", + "is_hidden_entity", "is_state", "is_state_attr", "state_attr", @@ -2331,7 +2329,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "area_name", "has_value", ] - hass_tests = ["has_value"] + hass_tests = [ + "has_value", + "is_hidden_entity", + "is_state", + "is_state_attr", + ] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: @@ -2345,6 +2348,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["closest"] = hassfunction(closest) self.filters["closest"] = pass_context(hassfunction(closest_filter)) self.globals["distance"] = hassfunction(distance) + self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) + self.tests["is_hidden_entity"] = pass_eval_context( + self.globals["is_hidden_entity"] + ) self.globals["is_state"] = hassfunction(is_state) self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) self.globals["is_state_attr"] = hassfunction(is_state_attr) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b381775f1e..f185191d1b 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1463,6 +1463,11 @@ def test_is_hidden_entity( hass, ).async_render() + assert not template.Template( + f"{{{{ ['{visible_entity.entity_id}'] | select('is_hidden_entity') | first }}}}", + hass, + ).async_render() + def test_is_state(hass: HomeAssistant) -> None: """Test is_state method.""" From 576780be74c9a0e4ac2ad2e347afb59a043dd546 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:23:13 -0400 Subject: [PATCH 0353/1165] Unregister webhook when registering webhook with nuki fials (#90514) --- homeassistant/components/nuki/__init__.py | 43 +++++++++++++---------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 74245d30d4..8a7985fe28 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -25,6 +25,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.network import get_url from homeassistant.helpers.update_coordinator import ( @@ -146,23 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True ) - async def _stop_nuki(_: Event): - """Stop and remove the Nuki webhook.""" - webhook.async_unregister(hass, entry.entry_id) - try: - async with async_timeout.timeout(10): - await hass.async_add_executor_job( - _remove_webhook, bridge, entry.entry_id - ) - except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err - except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) - ) - webhook_url = webhook.async_generate_path(entry.entry_id) hass_url = get_url( hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False @@ -174,9 +158,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _register_webhook, bridge, entry.entry_id, url ) except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Invalid credentials for Bridge: {err}") from err except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Error communicating with Bridge: {err}") from err + + async def _stop_nuki(_: Event): + """Stop and remove the Nuki webhook.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, bridge, entry.entry_id + ) + except InvalidCredentialsException as err: + _LOGGER.error( + "Error unregistering webhook, invalid credentials for bridge: %s", err + ) + except RequestException as err: + _LOGGER.error("Error communicating with bridge: %s", err) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) + ) coordinator = NukiCoordinator(hass, bridge, locks, openers) From 4a319c73ab70b5a3079df00951a9e3268a0fd2b2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Mar 2023 16:38:35 +0200 Subject: [PATCH 0354/1165] Add a device to the sun (#90517) --- homeassistant/components/sun/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 527ccc4069..8a253566e2 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -15,6 +15,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -126,6 +128,12 @@ class SunSensor(SensorEntity): self._attr_unique_id = f"{entry_id}-{entity_description.key}" self.sun = sun + self._attr_device_info = DeviceInfo( + name="Sun", + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" From 705e68be9e3253f4740433c00b0f728f1c179f94 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 10:40:19 -0400 Subject: [PATCH 0355/1165] Bumped version to 2023.4.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 289f536089..fba1d65459 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index e7e82d2ed5..73d680092f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b0" +version = "2023.4.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From fd55d0f2ddcea0eb1fb24d89501db09ca627d109 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Mar 2023 17:15:12 +0200 Subject: [PATCH 0356/1165] Migrate old ZHA IasZone sensor state to zigpy cache (#90508) * Migrate old ZHA IasZone sensor state to zigpy cache * Use correct type for ZoneStatus * Test that migration happens * Test that migration only happens once * Fix parametrize --- homeassistant/components/zha/binary_sensor.py | 35 ++++++- tests/components/zha/test_binary_sensor.py | 92 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index b277b3fe26..4e3c7166bf 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -2,13 +2,16 @@ from __future__ import annotations import functools +from typing import Any + +from zigpy.zcl.clusters.security import IasZone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import STATE_ON, EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -164,6 +167,36 @@ class IASZone(BinarySensor): """Parse the raw attribute into a bool state.""" return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state + # temporary code to migrate old IasZone sensors to update attribute cache state once + # remove in 2024.4.0 + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return state attributes.""" + return {"migrated_to_cache": True} # writing new state means we're migrated + + # temporary migration code + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + # trigger migration if extra state attribute is not present + if "migrated_to_cache" not in last_state.attributes: + self.migrate_to_zigpy_cache(last_state) + + # temporary migration code + @callback + def migrate_to_zigpy_cache(self, last_state): + """Save old IasZone sensor state to attribute cache.""" + # previous HA versions did not update the attribute cache for IasZone sensors, so do it once here + # a HA state write is triggered shortly afterwards and writes the "migrated_to_cache" extra state attribute + if last_state.state == STATE_ON: + migrated_state = IasZone.ZoneStatus.Alarm_1 + else: + migrated_state = IasZone.ZoneStatus(0) + + self._channel.cluster.update_attribute( + IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state + ) + @MULTI_MATCH( channel_names="tuya_manufacturer", diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index d633e9173e..ec25295ed5 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -8,12 +8,15 @@ import zigpy.zcl.clusters.security as security from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.util import dt as dt_util from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, send_attributes_report, + update_attribute_cache, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -120,3 +123,92 @@ async def test_binary_sensor( # test rejoin await async_test_rejoin(hass, zigpy_device, [cluster], reporting) assert hass.states.get(entity_id).state == STATE_OFF + + +@pytest.fixture +def core_rs(hass_storage): + """Core.restore_state fixture.""" + + def _storage(entity_id, attributes, state): + now = dt_util.utcnow().isoformat() + + hass_storage[restore_state.STORAGE_KEY] = { + "version": restore_state.STORAGE_VERSION, + "key": restore_state.STORAGE_KEY, + "data": [ + { + "state": { + "entity_id": entity_id, + "state": str(state), + "attributes": attributes, + "last_changed": now, + "last_updated": now, + "context": { + "id": "3c2243ff5f30447eb12e7348cfd5b8ff", + "user_id": None, + }, + }, + "last_seen": now, + } + ], + } + return + + return _storage + + +@pytest.mark.parametrize( + "restored_state", + [ + STATE_ON, + STATE_OFF, + ], +) +async def test_binary_sensor_migration_not_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, + restored_state, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration to zigpy cache.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=restored_state, attributes={}) # migration sensor state + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == restored_state + + # confirm migration extra state attribute was set to True + assert hass.states.get(entity_id).attributes["migrated_to_cache"] + + +async def test_binary_sensor_migration_already_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration doesn't migrate multiple times.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=STATE_OFF, attributes={"migrated_to_cache": True}) + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + + cluster = zigpy_device.endpoints.get(1).ias_zone + cluster.PLUGGED_ATTR_READS = { + "zone_status": security.IasZone.ZoneStatus.Alarm_1, + } + update_attribute_cache(cluster) + + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_ON # matches attribute cache + assert hass.states.get(entity_id).attributes["migrated_to_cache"] From 565f311f5c7b7d20adee8e0445ae5ff48eb6cd4a Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Thu, 30 Mar 2023 19:37:03 +0200 Subject: [PATCH 0357/1165] Add EV charging remote services for BMW/Mini (#88759) * Add select for EV charging to bmw_connected_drive * Use snapshot for select tests, split select_option tests * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Further adjustments from code review --------- Co-authored-by: rikroe Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../bmw_connected_drive/__init__.py | 1 + .../components/bmw_connected_drive/select.py | 139 + .../bmw_connected_drive/conftest.py | 9 + ...x-crccs_v2_vehicles_WBA00000000DEMO02.json | 80 + .../G26/bmw-eadrax-vcs_v4_vehicles.json | 50 + ...s_v4_vehicles_state_WBA00000000DEMO02.json | 313 ++ .../snapshots/test_diagnostics.ambr | 2914 +++++++++++++++-- .../snapshots/test_select.ambr | 97 + .../bmw_connected_drive/test_select.py | 84 + 9 files changed, 3356 insertions(+), 331 deletions(-) create mode 100644 homeassistant/components/bmw_connected_drive/select.py create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json create mode 100644 tests/components/bmw_connected_drive/snapshots/test_select.ambr create mode 100644 tests/components/bmw_connected_drive/test_select.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index a47f2bed59..e91943034d 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -41,6 +41,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.LOCK, Platform.NOTIFY, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py new file mode 100644 index 0000000000..e8e8dd5ca4 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -0,0 +1,139 @@ +"""Select platform for BMW.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.vehicle import MyBMWVehicle +from bimmer_connected.vehicle.charging_profile import ChargingMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + current_option: Callable[[MyBMWVehicle], str] + remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): + """Describes BMW sensor entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { + # --- Generic --- + "target_soc": BMWSelectEntityDescription( + key="target_soc", + name="Target SoC", + is_available=lambda v: v.is_remote_set_target_soc_enabled, + options=[str(i * 5 + 20) for i in range(17)], + current_option=lambda v: str(v.fuel_and_battery.charging_target), + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + target_soc=int(o) + ), + icon="mdi:battery-charging-medium", + unit_of_measurement=PERCENTAGE, + ), + "ac_limit": BMWSelectEntityDescription( + key="ac_limit", + name="AC Charging Limit", + is_available=lambda v: v.is_remote_set_ac_limit_enabled, + dynamic_options=lambda v: [ + str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] + ], + current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + ac_limit=int(o) + ), + icon="mdi:current-ac", + unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + "charging_mode": BMWSelectEntityDescription( + key="charging_mode", + name="Charging Mode", + is_available=lambda v: v.is_charging_plan_supported, + options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN], + current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update( + charging_mode=ChargingMode(o) + ), + icon="mdi:vector-point-select", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW lock from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWSelect] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWSelect(coordinator, vehicle, description) + for description in SELECT_TYPES.values() + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWSelect(BMWBaseEntity, SelectEntity): + """Representation of BMW select entity.""" + + entity_description: BMWSelectEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWSelectEntityDescription, + ) -> None: + """Initialize an BMW select.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + if description.dynamic_options: + self._attr_options = description.dynamic_options(vehicle) + self._attr_current_option = description.current_option(vehicle) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug( + "Updating select '%s' of %s", self.entity_description.key, self.vehicle.name + ) + self._attr_current_option = self.entity_description.current_option(self.vehicle) + super()._handle_coordinator_update() + + async def async_select_option(self, option: str) -> None: + """Update to the vehicle.""" + _LOGGER.debug( + "Executing '%s' on vehicle '%s' to value '%s'", + self.entity_description.key, + self.vehicle.vin, + option, + ) + await self.entity_description.remote_service(self.vehicle, option) diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py index 887df4da60..73e8f9a9b9 100644 --- a/tests/components/bmw_connected_drive/conftest.py +++ b/tests/components/bmw_connected_drive/conftest.py @@ -1,6 +1,9 @@ """Fixtures for BMW tests.""" +from unittest.mock import AsyncMock + from bimmer_connected.api.authentication import MyBMWAuthentication +from bimmer_connected.vehicle.remote_services import RemoteServices, RemoteServiceStatus import pytest from . import mock_login, mock_vehicles @@ -11,5 +14,11 @@ async def bmw_fixture(monkeypatch): """Patch the MyBMW Login and mock HTTP calls.""" monkeypatch.setattr(MyBMWAuthentication, "login", mock_login) + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(return_value=RemoteServiceStatus({"eventStatus": "EXECUTED"})), + ) + with mock_vehicles(): yield mock_vehicles diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json new file mode 100644 index 0000000000..af850f1ff2 --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json @@ -0,0 +1,80 @@ +{ + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { + "chargingMode": "Sofort laden", + "chargingModeSemantics": "Sofort laden", + "departureTimer": ["Aus"], + "departureTimerSemantics": "Aus", + "preconditionForDeparture": "Aus", + "showDepartureTimers": false + }, + "chargingFlap": { + "permanentlyUnlockLabel": "Aus" + }, + "chargingSettings": { + "acCurrentLimitLabel": "16A", + "acCurrentLimitLabelSemantics": "16 Ampere", + "chargingTargetLabel": "80%", + "dcLoudnessLabel": "Nicht begrenzt", + "unlockCableAutomaticallyLabel": "Aus" + } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "NO_PRESELECTION", + "endTimeSlot": "0001-01-01T00:00:00", + "startTimeSlot": "0001-01-01T00:00:00", + "type": "CHARGING_IMMEDIATELY" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [], + "id": 1, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 2, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "chargingFlapDetail": { + "isPermanentlyUnlock": false + }, + "chargingSettingsDetail": { + "acLimit": { + "current": { + "unit": "A", + "value": 16 + }, + "isUnlimited": false, + "max": 32, + "min": 6, + "values": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20, 32] + }, + "chargingTarget": 80, + "dcLoudness": "UNLIMITED_LOUD", + "isUnlockCableActive": false, + "minChargingTargetToWarning": 0 + }, + "servicePack": "WAVE_01" +} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json new file mode 100644 index 0000000000..f954fb103a --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json @@ -0,0 +1,50 @@ +[ + { + "appVehicleType": "DEMO", + "attributes": { + "a4aType": "NOT_SUPPORTED", + "bodyType": "G26", + "brand": "BMW", + "color": 4284245350, + "countryOfOrigin": "DE", + "driveTrain": "ELECTRIC", + "driverGuideInfo": { + "androidAppScheme": "com.bmwgroup.driversguide.row", + "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", + "iosAppScheme": "bmwdriversguide:///open", + "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" + }, + "headUnitRaw": "HU_MGU", + "headUnitType": "MGU", + "hmiVersion": "ID8", + "lastFetched": "2023-01-04T14:57:06.019Z", + "model": "i4 eDrive40", + "softwareVersionCurrent": { + "iStep": 470, + "puStep": { + "month": 11, + "year": 21 + }, + "seriesCluster": "G026" + }, + "softwareVersionExFactory": { + "iStep": 470, + "puStep": { + "month": 11, + "year": 21 + }, + "seriesCluster": "G026" + }, + "telematicsUnit": "WAVE01", + "year": 2021 + }, + "mappingInfo": { + "isAssociated": false, + "isLmmEnabled": false, + "isPrimaryUser": true, + "lmmStatusReasons": [], + "mappingStatus": "CONFIRMED" + }, + "vin": "WBA00000000DEMO02" + } +] diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json new file mode 100644 index 0000000000..8a0be88edf --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json @@ -0,0 +1,313 @@ +{ + "capabilities": { + "a4aType": "NOT_SUPPORTED", + "checkSustainabilityDPP": false, + "climateFunction": "AIR_CONDITIONING", + "climateNow": true, + "digitalKey": { + "bookedServicePackage": "SMACC_1_5", + "readerGraphics": "readerGraphics", + "state": "ACTIVATED" + }, + "horn": true, + "isBmwChargingSupported": true, + "isCarSharingSupported": false, + "isChargeNowForBusinessSupported": true, + "isChargingHistorySupported": true, + "isChargingHospitalityEnabled": true, + "isChargingLoudnessEnabled": true, + "isChargingPlanSupported": true, + "isChargingPowerLimitEnabled": true, + "isChargingSettingsEnabled": true, + "isChargingTargetSocEnabled": true, + "isClimateTimerWeeklyActive": false, + "isCustomerEsimSupported": true, + "isDCSContractManagementSupported": true, + "isDataPrivacyEnabled": false, + "isEasyChargeEnabled": true, + "isEvGoChargingSupported": false, + "isMiniChargingSupported": false, + "isNonLscFeatureEnabled": false, + "isPersonalPictureUploadSupported": false, + "isRemoteEngineStartSupported": false, + "isRemoteHistoryDeletionSupported": false, + "isRemoteHistorySupported": true, + "isRemoteParkingSupported": false, + "isRemoteServicesActivationRequired": false, + "isRemoteServicesBookingRequired": false, + "isScanAndChargeSupported": true, + "isSustainabilityAccumulatedViewEnabled": false, + "isSustainabilitySupported": false, + "isWifiHotspotServiceSupported": false, + "lastStateCallState": "ACTIVATED", + "lights": true, + "lock": true, + "remote360": true, + "remoteChargingCommands": {}, + "remoteSoftwareUpgrade": true, + "sendPoi": true, + "specialThemeSupport": [], + "speechThirdPartyAlexa": false, + "speechThirdPartyAlexaSDK": false, + "unlock": true, + "vehicleFinder": true, + "vehicleStateSource": "LAST_STATE_CALL" + }, + "state": { + "chargingProfile": { + "chargingControlType": "WEEKLY_PLANNER", + "chargingMode": "IMMEDIATE_CHARGING", + "chargingPreference": "NO_PRESELECTION", + "chargingSettings": { + "acCurrentLimit": 16, + "hospitality": "NO_ACTION", + "idcc": "UNLIMITED_LOUD", + "targetSoc": 80 + }, + "departureTimes": [ + { + "action": "DEACTIVATE", + "id": 1, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 2, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 3, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 4, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + } + ] + }, + "checkControlMessages": [ + { + "severity": "LOW", + "type": "TIRE_PRESSURE" + } + ], + "climateControlState": { + "activity": "STANDBY" + }, + "climateTimers": [ + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": false, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + }, + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": true, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + }, + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": true, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + } + ], + "combustionFuelLevel": {}, + "currentMileage": 1121, + "doorsState": { + "combinedSecurityState": "LOCKED", + "combinedState": "CLOSED", + "hood": "CLOSED", + "leftFront": "CLOSED", + "leftRear": "CLOSED", + "rightFront": "CLOSED", + "rightRear": "CLOSED", + "trunk": "CLOSED" + }, + "driverPreferences": { + "lscPrivacyMode": "OFF" + }, + "electricChargingState": { + "chargingConnectionType": "UNKNOWN", + "chargingLevelPercent": 80, + "chargingStatus": "INVALID", + "chargingTarget": 80, + "isChargerConnected": false, + "range": 472, + "remainingChargingMinutes": 10 + }, + "isLeftSteering": true, + "isLscSupported": true, + "lastFetched": "2023-01-04T14:57:06.386Z", + "lastUpdatedAt": "2023-01-04T14:57:06.407Z", + "location": { + "address": { + "formatted": "Am Olympiapark 1, 80809 München" + }, + "coordinates": { + "latitude": 48.177334, + "longitude": 11.556274 + }, + "heading": 180 + }, + "range": 472, + "requiredServices": [ + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "BRAKE_FLUID" + }, + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "VEHICLE_TUV" + }, + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "VEHICLE_CHECK" + }, + { + "status": "OK", + "type": "TIRE_WEAR_REAR" + }, + { + "status": "OK", + "type": "TIRE_WEAR_FRONT" + } + ], + "tireState": { + "frontLeft": { + "details": { + "dimension": "225/35 R20 90Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 4021, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461756", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 241, + "pressureStatus": 0, + "targetPressure": 269, + "wearStatus": 0 + } + }, + "frontRight": { + "details": { + "dimension": "225/35 R20 90Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 2419, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461756", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 255, + "pressureStatus": 0, + "targetPressure": 269, + "wearStatus": 0 + } + }, + "rearLeft": { + "details": { + "dimension": "255/30 R20 92Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 1219, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461757", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 324, + "pressureStatus": 0, + "targetPressure": 303, + "wearStatus": 0 + } + }, + "rearRight": { + "details": { + "dimension": "255/30 R20 92Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 1219, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461757", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 331, + "pressureStatus": 0, + "targetPressure": 303, + "wearStatus": 0 + } + } + }, + "windowsState": { + "combinedState": "CLOSED", + "leftFront": "CLOSED", + "leftRear": "CLOSED", + "rear": "CLOSED", + "rightFront": "CLOSED", + "rightRear": "CLOSED" + } + } +} diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 349706f593..2cd6622d14 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -2,6 +2,824 @@ # name: test_config_entry_diagnostics dict({ 'data': list([ + dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + 'ac_current_limit': 16, + 'charging_mode': 'IMMEDIATE_CHARGING', + 'charging_preferences': 'NO_PRESELECTION', + 'charging_preferences_service_pack': 'WAVE_01', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 1, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 2, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 4, + 'weekdays': list([ + ]), + }), + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + }), + 'end_time': '00:00:00', + 'start_time': '00:00:00', + }), + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + dict({ + 'description_long': None, + 'description_short': 'TIRE_PRESSURE', + 'state': 'LOW', + }), + ]), + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': None, + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'TIRE_WEAR_REAR', + 'state': 'OK', + }), + dict({ + 'due_date': None, + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'TIRE_WEAR_FRONT', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'LOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'ELECTRIC', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': '2022-07-10T11:10:00+00:00', + 'charging_start_time': None, + 'charging_start_time_no_tz': None, + 'charging_status': 'NOT_CHARGING', + 'charging_target': 80, + 'is_charger_connected': False, + 'remaining_battery_percent': 80, + 'remaining_fuel': list([ + None, + None, + ]), + 'remaining_fuel_percent': None, + 'remaining_range_electric': list([ + 472, + 'km', + ]), + 'remaining_range_fuel': list([ + None, + None, + ]), + 'remaining_range_total': list([ + 472, + 'km', + ]), + }), + 'has_combustion_drivetrain': False, + 'has_electric_drivetrain': True, + 'is_charging_plan_supported': True, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': True, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': True, + 'is_remote_set_target_soc_enabled': True, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': True, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 1121, + 'km', + ]), + 'name': 'i4 eDrive40', + 'timestamp': '2023-01-04T14:57:06+00:00', + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': '**REDACTED**', + 'location': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'remote_service_position': None, + 'vehicle_update_timestamp': '2023-01-04T14:57:06+00:00', + }), + 'vin': '**REDACTED**', + }), dict({ 'available_attributes': list([ 'gps_position', @@ -661,6 +1479,55 @@ 'fingerprint': list([ dict({ 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'CONNECTED', 'attributes': dict({ @@ -714,6 +1581,435 @@ ]), 'filename': 'mini-eadrax-vcs_v4_vehicles.json', }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), dict({ 'content': dict({ 'capabilities': dict({ @@ -933,7 +2229,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', }), dict({ 'content': dict({ @@ -998,7 +2294,7 @@ }), 'servicePack': 'TCB1', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', }), ]), 'info': dict({ @@ -1670,359 +2966,54 @@ dict({ 'content': list([ dict({ - 'appVehicleType': 'CONNECTED', + 'appVehicleType': 'DEMO', 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', 'driverGuideInfo': dict({ 'androidAppScheme': 'com.bmwgroup.driversguide.row', 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', 'iosAppScheme': 'bmwdriversguide:///open', 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-07-10T09:25:53.104Z', - 'model': 'i3 (+ REX)', + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', 'softwareVersionCurrent': dict({ - 'iStep': 510, + 'iStep': 470, 'puStep': dict({ 'month': 11, 'year': 21, }), - 'seriesCluster': 'I001', + 'seriesCluster': 'G026', }), 'softwareVersionExFactory': dict({ - 'iStep': 502, + 'iStep': 470, 'puStep': dict({ - 'month': 3, - 'year': 15, + 'month': 11, + 'year': 21, }), - 'seriesCluster': 'I001', + 'seriesCluster': 'G026', }), - 'year': 2015, + 'telematicsUnit': 'WAVE01', + 'year': 2021, }), 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), 'mappingStatus': 'CONFIRMED', }), 'vin': '**REDACTED**', }), - ]), - 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', - }), - dict({ - 'content': list([ - ]), - 'filename': 'mini-eadrax-vcs_v4_vehicles.json', - }), - dict({ - 'content': dict({ - 'capabilities': dict({ - 'climateFunction': 'AIR_CONDITIONING', - 'climateNow': True, - 'climateTimerTrigger': 'DEPARTURE_TIMER', - 'horn': True, - 'isBmwChargingSupported': True, - 'isCarSharingSupported': False, - 'isChargeNowForBusinessSupported': False, - 'isChargingHistorySupported': True, - 'isChargingHospitalityEnabled': False, - 'isChargingLoudnessEnabled': False, - 'isChargingPlanSupported': True, - 'isChargingPowerLimitEnabled': False, - 'isChargingSettingsEnabled': False, - 'isChargingTargetSocEnabled': False, - 'isClimateTimerSupported': True, - 'isCustomerEsimSupported': False, - 'isDCSContractManagementSupported': True, - 'isDataPrivacyEnabled': False, - 'isEasyChargeEnabled': False, - 'isEvGoChargingSupported': False, - 'isMiniChargingSupported': False, - 'isNonLscFeatureEnabled': False, - 'isRemoteEngineStartSupported': False, - 'isRemoteHistoryDeletionSupported': False, - 'isRemoteHistorySupported': True, - 'isRemoteParkingSupported': False, - 'isRemoteServicesActivationRequired': False, - 'isRemoteServicesBookingRequired': False, - 'isScanAndChargeSupported': False, - 'isSustainabilitySupported': False, - 'isWifiHotspotServiceSupported': False, - 'lastStateCallState': 'ACTIVATED', - 'lights': True, - 'lock': True, - 'remoteChargingCommands': dict({ - }), - 'sendPoi': True, - 'specialThemeSupport': list([ - ]), - 'unlock': True, - 'vehicleFinder': False, - 'vehicleStateSource': 'LAST_STATE_CALL', - }), - 'state': dict({ - 'chargingProfile': dict({ - 'chargingControlType': 'WEEKLY_PLANNER', - 'chargingMode': 'DELAYED_CHARGING', - 'chargingPreference': 'CHARGING_WINDOW', - 'chargingSettings': dict({ - 'hospitality': 'NO_ACTION', - 'idcc': 'NO_ACTION', - 'targetSoc': 100, - }), - 'climatisationOn': False, - 'departureTimes': list([ - dict({ - 'action': 'DEACTIVATE', - 'id': 1, - 'timeStamp': dict({ - 'hour': 7, - 'minute': 35, - }), - 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - ]), - }), - dict({ - 'action': 'DEACTIVATE', - 'id': 2, - 'timeStamp': dict({ - 'hour': 18, - 'minute': 0, - }), - 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', - ]), - }), - dict({ - 'action': 'DEACTIVATE', - 'id': 3, - 'timeStamp': dict({ - 'hour': 7, - 'minute': 0, - }), - 'timerWeekDays': list([ - ]), - }), - dict({ - 'action': 'DEACTIVATE', - 'id': 4, - 'timerWeekDays': list([ - ]), - }), - ]), - 'reductionOfChargeCurrent': dict({ - 'end': dict({ - 'hour': 1, - 'minute': 30, - }), - 'start': dict({ - 'hour': 18, - 'minute': 1, - }), - }), - }), - 'checkControlMessages': list([ - ]), - 'climateTimers': list([ - dict({ - 'departureTime': dict({ - 'hour': 6, - 'minute': 40, - }), - 'isWeeklyTimer': True, - 'timerAction': 'ACTIVATE', - 'timerWeekDays': list([ - 'THURSDAY', - 'SUNDAY', - ]), - }), - dict({ - 'departureTime': dict({ - 'hour': 12, - 'minute': 50, - }), - 'isWeeklyTimer': False, - 'timerAction': 'ACTIVATE', - 'timerWeekDays': list([ - 'MONDAY', - ]), - }), - dict({ - 'departureTime': dict({ - 'hour': 18, - 'minute': 59, - }), - 'isWeeklyTimer': True, - 'timerAction': 'DEACTIVATE', - 'timerWeekDays': list([ - 'WEDNESDAY', - ]), - }), - ]), - 'combustionFuelLevel': dict({ - 'range': 105, - 'remainingFuelLiters': 6, - 'remainingFuelPercent': 65, - }), - 'currentMileage': 137009, - 'doorsState': dict({ - 'combinedSecurityState': 'UNLOCKED', - 'combinedState': 'CLOSED', - 'hood': 'CLOSED', - 'leftFront': 'CLOSED', - 'leftRear': 'CLOSED', - 'rightFront': 'CLOSED', - 'rightRear': 'CLOSED', - 'trunk': 'CLOSED', - }), - 'driverPreferences': dict({ - 'lscPrivacyMode': 'OFF', - }), - 'electricChargingState': dict({ - 'chargingConnectionType': 'CONDUCTIVE', - 'chargingLevelPercent': 82, - 'chargingStatus': 'WAITING_FOR_CHARGING', - 'chargingTarget': 100, - 'isChargerConnected': True, - 'range': 174, - }), - 'isLeftSteering': True, - 'isLscSupported': True, - 'lastFetched': '2022-06-22T14:24:23.982Z', - 'lastUpdatedAt': '2022-06-22T13:58:52Z', - 'range': 174, - 'requiredServices': list([ - dict({ - 'dateTime': '2022-10-01T00:00:00.000Z', - 'description': 'Next service due by the specified date.', - 'status': 'OK', - 'type': 'BRAKE_FLUID', - }), - dict({ - 'dateTime': '2023-05-01T00:00:00.000Z', - 'description': 'Next vehicle check due after the specified distance or date.', - 'status': 'OK', - 'type': 'VEHICLE_CHECK', - }), - dict({ - 'dateTime': '2023-05-01T00:00:00.000Z', - 'description': 'Next state inspection due by the specified date.', - 'status': 'OK', - 'type': 'VEHICLE_TUV', - }), - ]), - 'roofState': dict({ - 'roofState': 'CLOSED', - 'roofStateType': 'SUN_ROOF', - }), - 'windowsState': dict({ - 'combinedState': 'CLOSED', - 'leftFront': 'CLOSED', - 'rightFront': 'CLOSED', - }), - }), - }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', - }), - dict({ - 'content': dict({ - 'chargeAndClimateSettings': dict({ - 'chargeAndClimateTimer': dict({ - 'showDepartureTimers': False, - }), - }), - 'chargeAndClimateTimerDetail': dict({ - 'chargingMode': dict({ - 'chargingPreference': 'CHARGING_WINDOW', - 'endTimeSlot': '0001-01-01T01:30:00', - 'startTimeSlot': '0001-01-01T18:01:00', - 'type': 'TIME_SLOT', - }), - 'departureTimer': dict({ - 'type': 'WEEKLY_DEPARTURE_TIMER', - 'weeklyTimers': list([ - dict({ - 'daysOfTheWeek': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - ]), - 'id': 1, - 'time': '0001-01-01T07:35:00', - 'timerAction': 'DEACTIVATE', - }), - dict({ - 'daysOfTheWeek': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', - ]), - 'id': 2, - 'time': '0001-01-01T18:00:00', - 'timerAction': 'DEACTIVATE', - }), - dict({ - 'daysOfTheWeek': list([ - ]), - 'id': 3, - 'time': '0001-01-01T07:00:00', - 'timerAction': 'DEACTIVATE', - }), - dict({ - 'daysOfTheWeek': list([ - ]), - 'id': 4, - 'time': '0001-01-01T00:00:00', - 'timerAction': 'DEACTIVATE', - }), - ]), - }), - 'isPreconditionForDepartureActive': False, - }), - 'servicePack': 'TCB1', - }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', - }), - ]), - 'info': dict({ - 'password': '**REDACTED**', - 'refresh_token': '**REDACTED**', - 'region': 'rest_of_world', - 'username': '**REDACTED**', - }), - }) -# --- -# name: test_device_diagnostics_vehicle_not_found - dict({ - 'data': None, - 'fingerprint': list([ - dict({ - 'content': list([ dict({ 'appVehicleType': 'CONNECTED', 'attributes': dict({ @@ -2076,6 +3067,435 @@ ]), 'filename': 'mini-eadrax-vcs_v4_vehicles.json', }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), dict({ 'content': dict({ 'capabilities': dict({ @@ -2295,7 +3715,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', }), dict({ 'content': dict({ @@ -2360,7 +3780,839 @@ }), 'servicePack': 'TCB1', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- +# name: test_device_diagnostics_vehicle_not_found + dict({ + 'data': None, + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', }), ]), 'info': dict({ diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr new file mode 100644 index 0000000000..e6902fbacf --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Target SoC', + 'icon': 'mdi:battery-charging-medium', + 'options': list([ + '20', + '25', + '30', + '35', + '40', + '45', + '50', + '55', + '60', + '65', + '70', + '75', + '80', + '85', + '90', + '95', + '100', + ]), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'select.i4_edrive40_target_soc', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 AC Charging Limit', + 'icon': 'mdi:current-ac', + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'select.i4_edrive40_ac_charging_limit', + 'last_changed': , + 'last_updated': , + 'state': '16', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging Mode', + 'icon': 'mdi:vector-point-select', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.i4_edrive40_charging_mode', + 'last_changed': , + 'last_updated': , + 'state': 'IMMEDIATE_CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging Mode', + 'icon': 'mdi:vector-point-select', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.i3_rex_charging_mode', + 'last_changed': , + 'last_updated': , + 'state': 'DELAYED_CHARGING', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py new file mode 100644 index 0000000000..92daf157a7 --- /dev/null +++ b/tests/components/bmw_connected_drive/test_select.py @@ -0,0 +1,84 @@ +"""Test BMW selects.""" +from bimmer_connected.vehicle.remote_services import RemoteServices +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_mocked_integration + + +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test select options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all select entities + assert hass.states.async_all("select") == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("select.i3_rex_charging_mode", "IMMEDIATE_CHARGING"), + ("select.i4_edrive40_ac_charging_limit", "16"), + ("select.i4_edrive40_target_soc", "80"), + ("select.i4_edrive40_charging_mode", "DELAYED_CHARGING"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test allowed values for select inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + await hass.services.async_call( + "select", + "select_option", + service_data={"option": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("select.i4_edrive40_ac_charging_limit", "17"), + ("select.i4_edrive40_target_soc", "81"), + ], +) +async def test_update_triggers_fail( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test not allowed values for select inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + with pytest.raises(ValueError): + await hass.services.async_call( + "select", + "select_option", + service_data={"option": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 0 From 6f8939025189fe7b712273150f3fca6f0ae275f9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Mar 2023 19:48:21 +0200 Subject: [PATCH 0358/1165] Update frontend to 20230330.0 (#90524) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 8c3fb8c143..6a2a904833 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==20230329.0"] + "requirements": ["home-assistant-frontend==20230330.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0ed98c78e1..342942f0dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index cae40bd2c6..3cbd6bd365 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 314cd2efdf..9bdb5c485e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 6b0c98045ef79394766b4aa8738410ab1d042d7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:53:47 -1000 Subject: [PATCH 0359/1165] Handle garbage in the context_id column during migration (#90544) * Handle garbage in the context_id column during migration * Update homeassistant/components/recorder/migration.py * lint --- .../components/recorder/migration.py | 14 ++++-- tests/components/recorder/test_migrate.py | 45 ++++++++++++++++++- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4be0132765..3e7f9aa592 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1355,10 +1355,16 @@ def _context_id_to_bytes(context_id: str | None) -> bytes | None: """Convert a context_id to bytes.""" if context_id is None: return None - if len(context_id) == 32: - return UUID(context_id).bytes - if len(context_id) == 26: - return ulid_to_bytes(context_id) + with contextlib.suppress(ValueError): + # There may be garbage in the context_id column + # from custom integrations that are not UUIDs or + # ULIDs that filled the column to the max length + # so we need to catch the ValueError and return + # None if it happens + if len(context_id) == 32: + return UUID(context_id).bytes + if len(context_id) == 26: + return ulid_to_bytes(context_id) return None diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index fe4f1e016f..efe2a51b83 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -671,6 +671,19 @@ async def test_migrate_events_context_ids( context_parent_id=None, context_parent_id_bin=None, ), + Events( + event_type="garbage_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -695,12 +708,13 @@ async def test_migrate_events_context_ids( "empty_context_id_event", "ulid_context_id_event", "invalid_context_id_event", + "garbage_context_id_event", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {event.event_type: _object_as_dict(event) for event in events} events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) @@ -746,6 +760,14 @@ async def test_migrate_events_context_ids( assert invalid_context_id_event["context_user_id_bin"] is None assert invalid_context_id_event["context_parent_id_bin"] is None + garbage_context_id_event = events_by_type["garbage_context_id_event"] + assert garbage_context_id_event["context_id"] is None + assert garbage_context_id_event["context_user_id"] is None + assert garbage_context_id_event["context_parent_id"] is None + assert garbage_context_id_event["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id_event["context_user_id_bin"] is None + assert garbage_context_id_event["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_context_ids", [True]) async def test_migrate_states_context_ids( @@ -803,6 +825,16 @@ async def test_migrate_states_context_ids( context_parent_id=None, context_parent_id_bin=None, ), + States( + entity_id="state.garbage_context_id", + last_updated_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -827,12 +859,13 @@ async def test_migrate_states_context_ids( "state.empty_context_id", "state.ulid_context_id", "state.invalid_context_id", + "state.garbage_context_id", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {state.entity_id: _object_as_dict(state) for state in events} states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) @@ -877,6 +910,14 @@ async def test_migrate_states_context_ids( assert invalid_context_id["context_user_id_bin"] is None assert invalid_context_id["context_parent_id_bin"] is None + garbage_context_id = states_by_entity_id["state.garbage_context_id"] + assert garbage_context_id["context_id"] is None + assert garbage_context_id["context_user_id"] is None + assert garbage_context_id["context_parent_id"] is None + assert garbage_context_id["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id["context_user_id_bin"] is None + assert garbage_context_id["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) async def test_migrate_event_type_ids( From a2efe2445aa5b441a1103b8e92a061b0b469b493 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:54:13 -1000 Subject: [PATCH 0360/1165] Fix migration when encountering a NULL entity_id/event_type (#90542) * Fix migration when encountering a NULL entity_id/event_type reported in #beta on discord * simplify --- .../components/recorder/migration.py | 30 ++-- tests/components/recorder/test_migrate.py | 150 +++++++++++++++++- 2 files changed, 168 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 3e7f9aa592..23382a9aeb 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1445,12 +1445,15 @@ def migrate_event_type_ids(instance: Recorder) -> bool: with session_scope(session=session_maker()) as session: if events := session.execute(find_event_type_to_migrate()).all(): event_types = {event_type for _, event_type in events} + if None in event_types: + # event_type should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + event_types.remove(None) + event_types.add(_EMPTY_EVENT_TYPE) + event_type_to_id = event_type_manager.get_many(event_types, session) if missing_event_types := { - # We should never see see None for the event_Type in the events table - # but we need to be defensive so we don't fail the migration - # because of a bad event - _EMPTY_EVENT_TYPE if event_type is None else event_type + event_type for event_type, event_id in event_type_to_id.items() if event_id is None }: @@ -1476,7 +1479,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: { "event_id": event_id, "event_type": None, - "event_type_id": event_type_to_id[event_type], + "event_type_id": event_type_to_id[ + _EMPTY_EVENT_TYPE if event_type is None else event_type + ], } for event_id, event_type in events ], @@ -1508,14 +1513,17 @@ def migrate_entity_ids(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: if states := session.execute(find_entity_ids_to_migrate()).all(): entity_ids = {entity_id for _, entity_id in states} + if None in entity_ids: + # entity_id should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + entity_ids.remove(None) + entity_ids.add(_EMPTY_ENTITY_ID) + entity_id_to_metadata_id = states_meta_manager.get_many( entity_ids, session, True ) if missing_entity_ids := { - # We should never see _EMPTY_ENTITY_ID in the states table - # but we need to be defensive so we don't fail the migration - # because of a bad state - _EMPTY_ENTITY_ID if entity_id is None else entity_id + entity_id for entity_id, metadata_id in entity_id_to_metadata_id.items() if metadata_id is None }: @@ -1543,7 +1551,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: # the history queries still need to work while the # migration is in progress and we will do this in # post_migrate_entity_ids - "metadata_id": entity_id_to_metadata_id[entity_id], + "metadata_id": entity_id_to_metadata_id[ + _EMPTY_ENTITY_ID if entity_id is None else entity_id + ], } for state_id, entity_id in states ], diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index efe2a51b83..b75d536d15 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -998,7 +998,7 @@ async def test_migrate_entity_ids( instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) - def _insert_events(): + def _insert_states(): with session_scope(hass=hass) as session: session.add_all( ( @@ -1020,7 +1020,7 @@ async def test_migrate_entity_ids( ) ) - await instance.async_add_executor_job(_insert_events) + await instance.async_add_executor_job(_insert_states) await async_wait_recording_done(hass) # This is a threadsafe way to add a task to the recorder @@ -1106,3 +1106,149 @@ async def test_post_migrate_entity_ids( assert states_by_state["one_1"] is None assert states_by_state["two_2"] is None assert states_by_state["two_1"] is None + + +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +async def test_migrate_null_entity_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate entity_ids to the StatesMeta table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_states(): + with session_scope(hass=hass) as session: + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=1.452529, + ), + ) + session.add_all( + States( + entity_id=None, + state="empty", + last_updated_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_states) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EntityIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = ( + session.query( + States.state, + States.metadata_id, + States.last_updated_ts, + StatesMeta.entity_id, + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .all() + ) + assert len(states) == 1002 + result = {} + for state in states: + result.setdefault(state.entity_id, []).append( + { + "state_id": state.entity_id, + "last_updated_ts": state.last_updated_ts, + "state": state.state, + } + ) + return result + + states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) + assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000 + assert len(states_by_entity_id["sensor.one"]) == 2 + + +@pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) +async def test_migrate_null_event_type_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=1.452529, + ), + ) + session.add_all( + Events( + event_type=None, + origin_idx=0, + time_fired_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + + instance.queue_task(EventTypeIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_events(): + with session_scope(hass=hass) as session: + events = ( + session.query(Events.event_id, Events.time_fired, EventTypes.event_type) + .filter( + Events.event_type_id.in_( + select_event_type_ids( + ( + "event_type_one", + migration._EMPTY_EVENT_TYPE, + ) + ) + ) + ) + .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) + .all() + ) + assert len(events) == 1002 + result = {} + for event in events: + result.setdefault(event.event_type, []).append( + { + "event_id": event.event_id, + "time_fired": event.time_fired, + "event_type": event.event_type, + } + ) + return result + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 From 47af325a88db585e96299deced3098ed369e89ca Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 02:54:31 +0200 Subject: [PATCH 0361/1165] Add entity name translations to LaMetric (#90538) * Add entity name translations to LaMetric * Consistency --- homeassistant/components/lametric/button.py | 8 +++--- homeassistant/components/lametric/select.py | 3 +-- homeassistant/components/lametric/sensor.py | 1 + .../components/lametric/strings.json | 25 +++++++++++++++++++ homeassistant/components/lametric/switch.py | 2 +- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 74edd9e0af..18a0c2f8f7 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -36,28 +36,28 @@ class LaMetricButtonEntityDescription( BUTTONS = [ LaMetricButtonEntityDescription( key="app_next", - name="Next app", + translation_key="app_next", icon="mdi:arrow-right-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_next(), ), LaMetricButtonEntityDescription( key="app_previous", - name="Previous app", + translation_key="app_previous", icon="mdi:arrow-left-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_previous(), ), LaMetricButtonEntityDescription( key="dismiss_current", - name="Dismiss current notification", + translation_key="dismiss_current", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_current_notification(), ), LaMetricButtonEntityDescription( key="dismiss_all", - name="Dismiss all notifications", + translation_key="dismiss_all", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_all_notifications(), diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index 295003c853..b7c0e55745 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -37,11 +37,10 @@ class LaMetricSelectEntityDescription( SELECTS = [ LaMetricSelectEntityDescription( key="brightness_mode", - name="Brightness mode", + translation_key="brightness_mode", icon="mdi:brightness-auto", entity_category=EntityCategory.CONFIG, options=["auto", "manual"], - translation_key="brightness_mode", current_fn=lambda device: device.display.brightness_mode.value, select_fn=lambda api, opt: api.display(brightness_mode=BrightnessMode(opt)), ), diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index c12d368efd..0c26d2c7dd 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -38,6 +38,7 @@ class LaMetricSensorEntityDescription( SENSORS = [ LaMetricSensorEntityDescription( key="rssi", + translation_key="rssi", name="Wi-Fi signal", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index eb90b21ff2..21cebe46f2 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -45,13 +45,38 @@ } }, "entity": { + "button": { + "app_next": { + "name": "Next app" + }, + "app_previous": { + "name": "Previous app" + }, + "dismiss_current": { + "name": "Dismiss current notification" + }, + "dismiss_all": { + "name": "Dismiss all notifications" + } + }, + "sensor": { + "rssi": { + "name": "Wi-Fi signal" + } + }, "select": { "brightness_mode": { + "name": "Brightness mode", "state": { "auto": "Automatic", "manual": "Manual" } } + }, + "switch": { + "bluetooth": { + "name": "Bluetooth" + } } } } diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index f6807648b7..c33ec16d61 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -39,7 +39,7 @@ class LaMetricSwitchEntityDescription( SWITCHES = [ LaMetricSwitchEntityDescription( key="bluetooth", - name="Bluetooth", + translation_key="bluetooth", icon="mdi:bluetooth", entity_category=EntityCategory.CONFIG, available_fn=lambda device: device.bluetooth.available, From 3a3c7389457204d10697fbfd2147034319b6fa38 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 30 Mar 2023 20:55:01 -0400 Subject: [PATCH 0362/1165] Bump ZHA dependencies (#90547) * Bump ZHA dependencies * Ensure the network is formed on channel 15 when multi-PAN is in use --- homeassistant/components/zha/core/const.py | 2 ++ homeassistant/components/zha/core/gateway.py | 16 +++++++++++ homeassistant/components/zha/manifest.json | 10 +++---- requirements_all.txt | 10 +++---- requirements_test_all.txt | 10 +++---- tests/components/zha/test_gateway.py | 29 ++++++++++++++++++++ 6 files changed, 62 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 4c10a2328a..6423723d32 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -137,6 +137,8 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" +CONF_NWK = "network" +CONF_NWK_CHANNEL = "channel" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 3f9ada1ed0..8858ea6959 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -41,6 +41,8 @@ from .const import ( ATTR_TYPE, CONF_DATABASE, CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, @@ -172,6 +174,20 @@ class ZHAGateway: ): app_config[CONF_USE_THREAD] = False + # Local import to avoid circular dependencies + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, + ) + + # Until we have a way to coordinate channels with the Thread half of multi-PAN, + # stick to the old zigpy default of channel 15 instead of dynamically scanning + if ( + is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH]) + and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None + ): + app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 + return app_controller_cls, app_controller_cls.SCHEMA(app_config) async def async_initialize(self) -> None: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d82fe5ed0f..bc5bf6a6d4 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.10", + "bellows==0.35.0", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.95", - "zigpy-deconz==0.19.2", - "zigpy==0.53.2", - "zigpy-xbee==0.16.2", + "zigpy-deconz==0.20.0", + "zigpy==0.54.0", + "zigpy-xbee==0.17.0", "zigpy-zigate==0.10.3", - "zigpy-znp==0.9.3" + "zigpy-znp==0.10.0" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 3cbd6bd365..8706e4e5f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -2710,19 +2710,19 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bdb5c485e..01b780f4cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,7 +355,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -1944,19 +1944,19 @@ zeversolar==0.3.1 zha-quirks==0.0.95 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zwave_js zwave-js-server-python==0.47.1 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 392c589ea1..be53b22be6 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -323,3 +323,32 @@ async def test_gateway_initialize_bellows_thread( await zha_gateway.async_initialize() assert mock_new.mock_calls[0].args[0]["use_thread"] is thread_state + + +@pytest.mark.parametrize( + ("device_path", "config_override", "expected_channel"), + [ + ("/dev/ttyUSB0", {}, None), + ("socket://192.168.1.123:9999", {}, None), + ("socket://192.168.1.123:9999", {"network": {"channel": 20}}, 20), + ("socket://core-silabs-multiprotocol:9999", {}, 15), + ("socket://core-silabs-multiprotocol:9999", {"network": {"channel": 20}}, 20), + ], +) +async def test_gateway_force_multi_pan_channel( + device_path: str, + config_override: dict, + expected_channel: int | None, + hass: HomeAssistant, + coordinator, +) -> None: + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + zha_gateway = get_zha_gateway(hass) + assert zha_gateway is not None + + zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) + zha_gateway.config_entry.data["device"]["path"] = device_path + zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + + _, config = zha_gateway.get_application_controller_data() + assert config["network"]["channel"] == expected_channel From 38aff23be50f0f645fdfcd15e6eec2af1f88cdae Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Mar 2023 17:15:12 +0200 Subject: [PATCH 0363/1165] Migrate old ZHA IasZone sensor state to zigpy cache (#90508) * Migrate old ZHA IasZone sensor state to zigpy cache * Use correct type for ZoneStatus * Test that migration happens * Test that migration only happens once * Fix parametrize --- homeassistant/components/zha/binary_sensor.py | 35 ++++++- tests/components/zha/test_binary_sensor.py | 92 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index b277b3fe26..4e3c7166bf 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -2,13 +2,16 @@ from __future__ import annotations import functools +from typing import Any + +from zigpy.zcl.clusters.security import IasZone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import STATE_ON, EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -164,6 +167,36 @@ class IASZone(BinarySensor): """Parse the raw attribute into a bool state.""" return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state + # temporary code to migrate old IasZone sensors to update attribute cache state once + # remove in 2024.4.0 + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return state attributes.""" + return {"migrated_to_cache": True} # writing new state means we're migrated + + # temporary migration code + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + # trigger migration if extra state attribute is not present + if "migrated_to_cache" not in last_state.attributes: + self.migrate_to_zigpy_cache(last_state) + + # temporary migration code + @callback + def migrate_to_zigpy_cache(self, last_state): + """Save old IasZone sensor state to attribute cache.""" + # previous HA versions did not update the attribute cache for IasZone sensors, so do it once here + # a HA state write is triggered shortly afterwards and writes the "migrated_to_cache" extra state attribute + if last_state.state == STATE_ON: + migrated_state = IasZone.ZoneStatus.Alarm_1 + else: + migrated_state = IasZone.ZoneStatus(0) + + self._channel.cluster.update_attribute( + IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state + ) + @MULTI_MATCH( channel_names="tuya_manufacturer", diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index d633e9173e..ec25295ed5 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -8,12 +8,15 @@ import zigpy.zcl.clusters.security as security from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.util import dt as dt_util from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, send_attributes_report, + update_attribute_cache, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -120,3 +123,92 @@ async def test_binary_sensor( # test rejoin await async_test_rejoin(hass, zigpy_device, [cluster], reporting) assert hass.states.get(entity_id).state == STATE_OFF + + +@pytest.fixture +def core_rs(hass_storage): + """Core.restore_state fixture.""" + + def _storage(entity_id, attributes, state): + now = dt_util.utcnow().isoformat() + + hass_storage[restore_state.STORAGE_KEY] = { + "version": restore_state.STORAGE_VERSION, + "key": restore_state.STORAGE_KEY, + "data": [ + { + "state": { + "entity_id": entity_id, + "state": str(state), + "attributes": attributes, + "last_changed": now, + "last_updated": now, + "context": { + "id": "3c2243ff5f30447eb12e7348cfd5b8ff", + "user_id": None, + }, + }, + "last_seen": now, + } + ], + } + return + + return _storage + + +@pytest.mark.parametrize( + "restored_state", + [ + STATE_ON, + STATE_OFF, + ], +) +async def test_binary_sensor_migration_not_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, + restored_state, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration to zigpy cache.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=restored_state, attributes={}) # migration sensor state + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == restored_state + + # confirm migration extra state attribute was set to True + assert hass.states.get(entity_id).attributes["migrated_to_cache"] + + +async def test_binary_sensor_migration_already_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration doesn't migrate multiple times.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=STATE_OFF, attributes={"migrated_to_cache": True}) + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + + cluster = zigpy_device.endpoints.get(1).ias_zone + cluster.PLUGGED_ATTR_READS = { + "zone_status": security.IasZone.ZoneStatus.Alarm_1, + } + update_attribute_cache(cluster) + + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_ON # matches attribute cache + assert hass.states.get(entity_id).attributes["migrated_to_cache"] From 8a99d2a566cd281155506d2c0222a2925fb1eee3 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Mar 2023 19:48:21 +0200 Subject: [PATCH 0364/1165] Update frontend to 20230330.0 (#90524) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 8c3fb8c143..6a2a904833 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==20230329.0"] + "requirements": ["home-assistant-frontend==20230330.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0ed98c78e1..342942f0dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index cae40bd2c6..3cbd6bd365 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75b7dfeb3b..b2b78b4541 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 9478518937eb1fb6b3cca8a8646eeb9aac2945e2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 02:54:31 +0200 Subject: [PATCH 0365/1165] Add entity name translations to LaMetric (#90538) * Add entity name translations to LaMetric * Consistency --- homeassistant/components/lametric/button.py | 8 +++--- homeassistant/components/lametric/select.py | 3 +-- homeassistant/components/lametric/sensor.py | 1 + .../components/lametric/strings.json | 25 +++++++++++++++++++ homeassistant/components/lametric/switch.py | 2 +- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 74edd9e0af..18a0c2f8f7 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -36,28 +36,28 @@ class LaMetricButtonEntityDescription( BUTTONS = [ LaMetricButtonEntityDescription( key="app_next", - name="Next app", + translation_key="app_next", icon="mdi:arrow-right-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_next(), ), LaMetricButtonEntityDescription( key="app_previous", - name="Previous app", + translation_key="app_previous", icon="mdi:arrow-left-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_previous(), ), LaMetricButtonEntityDescription( key="dismiss_current", - name="Dismiss current notification", + translation_key="dismiss_current", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_current_notification(), ), LaMetricButtonEntityDescription( key="dismiss_all", - name="Dismiss all notifications", + translation_key="dismiss_all", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_all_notifications(), diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index 295003c853..b7c0e55745 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -37,11 +37,10 @@ class LaMetricSelectEntityDescription( SELECTS = [ LaMetricSelectEntityDescription( key="brightness_mode", - name="Brightness mode", + translation_key="brightness_mode", icon="mdi:brightness-auto", entity_category=EntityCategory.CONFIG, options=["auto", "manual"], - translation_key="brightness_mode", current_fn=lambda device: device.display.brightness_mode.value, select_fn=lambda api, opt: api.display(brightness_mode=BrightnessMode(opt)), ), diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index c12d368efd..0c26d2c7dd 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -38,6 +38,7 @@ class LaMetricSensorEntityDescription( SENSORS = [ LaMetricSensorEntityDescription( key="rssi", + translation_key="rssi", name="Wi-Fi signal", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index eb90b21ff2..21cebe46f2 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -45,13 +45,38 @@ } }, "entity": { + "button": { + "app_next": { + "name": "Next app" + }, + "app_previous": { + "name": "Previous app" + }, + "dismiss_current": { + "name": "Dismiss current notification" + }, + "dismiss_all": { + "name": "Dismiss all notifications" + } + }, + "sensor": { + "rssi": { + "name": "Wi-Fi signal" + } + }, "select": { "brightness_mode": { + "name": "Brightness mode", "state": { "auto": "Automatic", "manual": "Manual" } } + }, + "switch": { + "bluetooth": { + "name": "Bluetooth" + } } } } diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index f6807648b7..c33ec16d61 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -39,7 +39,7 @@ class LaMetricSwitchEntityDescription( SWITCHES = [ LaMetricSwitchEntityDescription( key="bluetooth", - name="Bluetooth", + translation_key="bluetooth", icon="mdi:bluetooth", entity_category=EntityCategory.CONFIG, available_fn=lambda device: device.bluetooth.available, From e32d89215d39287f61a41e7c66d0681b1fe10c05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:54:13 -1000 Subject: [PATCH 0366/1165] Fix migration when encountering a NULL entity_id/event_type (#90542) * Fix migration when encountering a NULL entity_id/event_type reported in #beta on discord * simplify --- .../components/recorder/migration.py | 30 ++-- tests/components/recorder/test_migrate.py | 150 +++++++++++++++++- 2 files changed, 168 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4be0132765..fe1d7fdf91 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1439,12 +1439,15 @@ def migrate_event_type_ids(instance: Recorder) -> bool: with session_scope(session=session_maker()) as session: if events := session.execute(find_event_type_to_migrate()).all(): event_types = {event_type for _, event_type in events} + if None in event_types: + # event_type should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + event_types.remove(None) + event_types.add(_EMPTY_EVENT_TYPE) + event_type_to_id = event_type_manager.get_many(event_types, session) if missing_event_types := { - # We should never see see None for the event_Type in the events table - # but we need to be defensive so we don't fail the migration - # because of a bad event - _EMPTY_EVENT_TYPE if event_type is None else event_type + event_type for event_type, event_id in event_type_to_id.items() if event_id is None }: @@ -1470,7 +1473,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: { "event_id": event_id, "event_type": None, - "event_type_id": event_type_to_id[event_type], + "event_type_id": event_type_to_id[ + _EMPTY_EVENT_TYPE if event_type is None else event_type + ], } for event_id, event_type in events ], @@ -1502,14 +1507,17 @@ def migrate_entity_ids(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: if states := session.execute(find_entity_ids_to_migrate()).all(): entity_ids = {entity_id for _, entity_id in states} + if None in entity_ids: + # entity_id should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + entity_ids.remove(None) + entity_ids.add(_EMPTY_ENTITY_ID) + entity_id_to_metadata_id = states_meta_manager.get_many( entity_ids, session, True ) if missing_entity_ids := { - # We should never see _EMPTY_ENTITY_ID in the states table - # but we need to be defensive so we don't fail the migration - # because of a bad state - _EMPTY_ENTITY_ID if entity_id is None else entity_id + entity_id for entity_id, metadata_id in entity_id_to_metadata_id.items() if metadata_id is None }: @@ -1537,7 +1545,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: # the history queries still need to work while the # migration is in progress and we will do this in # post_migrate_entity_ids - "metadata_id": entity_id_to_metadata_id[entity_id], + "metadata_id": entity_id_to_metadata_id[ + _EMPTY_ENTITY_ID if entity_id is None else entity_id + ], } for state_id, entity_id in states ], diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index fe4f1e016f..6e54513830 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -957,7 +957,7 @@ async def test_migrate_entity_ids( instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) - def _insert_events(): + def _insert_states(): with session_scope(hass=hass) as session: session.add_all( ( @@ -979,7 +979,7 @@ async def test_migrate_entity_ids( ) ) - await instance.async_add_executor_job(_insert_events) + await instance.async_add_executor_job(_insert_states) await async_wait_recording_done(hass) # This is a threadsafe way to add a task to the recorder @@ -1065,3 +1065,149 @@ async def test_post_migrate_entity_ids( assert states_by_state["one_1"] is None assert states_by_state["two_2"] is None assert states_by_state["two_1"] is None + + +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +async def test_migrate_null_entity_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate entity_ids to the StatesMeta table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_states(): + with session_scope(hass=hass) as session: + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=1.452529, + ), + ) + session.add_all( + States( + entity_id=None, + state="empty", + last_updated_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_states) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EntityIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = ( + session.query( + States.state, + States.metadata_id, + States.last_updated_ts, + StatesMeta.entity_id, + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .all() + ) + assert len(states) == 1002 + result = {} + for state in states: + result.setdefault(state.entity_id, []).append( + { + "state_id": state.entity_id, + "last_updated_ts": state.last_updated_ts, + "state": state.state, + } + ) + return result + + states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) + assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000 + assert len(states_by_entity_id["sensor.one"]) == 2 + + +@pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) +async def test_migrate_null_event_type_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=1.452529, + ), + ) + session.add_all( + Events( + event_type=None, + origin_idx=0, + time_fired_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + + instance.queue_task(EventTypeIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_events(): + with session_scope(hass=hass) as session: + events = ( + session.query(Events.event_id, Events.time_fired, EventTypes.event_type) + .filter( + Events.event_type_id.in_( + select_event_type_ids( + ( + "event_type_one", + migration._EMPTY_EVENT_TYPE, + ) + ) + ) + ) + .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) + .all() + ) + assert len(events) == 1002 + result = {} + for event in events: + result.setdefault(event.event_type, []).append( + { + "event_id": event.event_id, + "time_fired": event.time_fired, + "event_type": event.event_type, + } + ) + return result + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 From aad1f4b7662811311ec70f6046325b025f0ee9ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:53:47 -1000 Subject: [PATCH 0367/1165] Handle garbage in the context_id column during migration (#90544) * Handle garbage in the context_id column during migration * Update homeassistant/components/recorder/migration.py * lint --- .../components/recorder/migration.py | 14 ++++-- tests/components/recorder/test_migrate.py | 45 ++++++++++++++++++- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index fe1d7fdf91..23382a9aeb 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1355,10 +1355,16 @@ def _context_id_to_bytes(context_id: str | None) -> bytes | None: """Convert a context_id to bytes.""" if context_id is None: return None - if len(context_id) == 32: - return UUID(context_id).bytes - if len(context_id) == 26: - return ulid_to_bytes(context_id) + with contextlib.suppress(ValueError): + # There may be garbage in the context_id column + # from custom integrations that are not UUIDs or + # ULIDs that filled the column to the max length + # so we need to catch the ValueError and return + # None if it happens + if len(context_id) == 32: + return UUID(context_id).bytes + if len(context_id) == 26: + return ulid_to_bytes(context_id) return None diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 6e54513830..b75d536d15 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -671,6 +671,19 @@ async def test_migrate_events_context_ids( context_parent_id=None, context_parent_id_bin=None, ), + Events( + event_type="garbage_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -695,12 +708,13 @@ async def test_migrate_events_context_ids( "empty_context_id_event", "ulid_context_id_event", "invalid_context_id_event", + "garbage_context_id_event", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {event.event_type: _object_as_dict(event) for event in events} events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) @@ -746,6 +760,14 @@ async def test_migrate_events_context_ids( assert invalid_context_id_event["context_user_id_bin"] is None assert invalid_context_id_event["context_parent_id_bin"] is None + garbage_context_id_event = events_by_type["garbage_context_id_event"] + assert garbage_context_id_event["context_id"] is None + assert garbage_context_id_event["context_user_id"] is None + assert garbage_context_id_event["context_parent_id"] is None + assert garbage_context_id_event["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id_event["context_user_id_bin"] is None + assert garbage_context_id_event["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_context_ids", [True]) async def test_migrate_states_context_ids( @@ -803,6 +825,16 @@ async def test_migrate_states_context_ids( context_parent_id=None, context_parent_id_bin=None, ), + States( + entity_id="state.garbage_context_id", + last_updated_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -827,12 +859,13 @@ async def test_migrate_states_context_ids( "state.empty_context_id", "state.ulid_context_id", "state.invalid_context_id", + "state.garbage_context_id", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {state.entity_id: _object_as_dict(state) for state in events} states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) @@ -877,6 +910,14 @@ async def test_migrate_states_context_ids( assert invalid_context_id["context_user_id_bin"] is None assert invalid_context_id["context_parent_id_bin"] is None + garbage_context_id = states_by_entity_id["state.garbage_context_id"] + assert garbage_context_id["context_id"] is None + assert garbage_context_id["context_user_id"] is None + assert garbage_context_id["context_parent_id"] is None + assert garbage_context_id["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id["context_user_id_bin"] is None + assert garbage_context_id["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) async def test_migrate_event_type_ids( From 4bf10c01f0df524c8048985c354f2159102eadb3 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 30 Mar 2023 20:55:01 -0400 Subject: [PATCH 0368/1165] Bump ZHA dependencies (#90547) * Bump ZHA dependencies * Ensure the network is formed on channel 15 when multi-PAN is in use --- homeassistant/components/zha/core/const.py | 2 ++ homeassistant/components/zha/core/gateway.py | 16 +++++++++++ homeassistant/components/zha/manifest.json | 10 +++---- requirements_all.txt | 10 +++---- requirements_test_all.txt | 10 +++---- tests/components/zha/test_gateway.py | 29 ++++++++++++++++++++ 6 files changed, 62 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 4c10a2328a..6423723d32 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -137,6 +137,8 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" +CONF_NWK = "network" +CONF_NWK_CHANNEL = "channel" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 3f9ada1ed0..8858ea6959 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -41,6 +41,8 @@ from .const import ( ATTR_TYPE, CONF_DATABASE, CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, @@ -172,6 +174,20 @@ class ZHAGateway: ): app_config[CONF_USE_THREAD] = False + # Local import to avoid circular dependencies + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, + ) + + # Until we have a way to coordinate channels with the Thread half of multi-PAN, + # stick to the old zigpy default of channel 15 instead of dynamically scanning + if ( + is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH]) + and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None + ): + app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 + return app_controller_cls, app_controller_cls.SCHEMA(app_config) async def async_initialize(self) -> None: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d82fe5ed0f..bc5bf6a6d4 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.10", + "bellows==0.35.0", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.95", - "zigpy-deconz==0.19.2", - "zigpy==0.53.2", - "zigpy-xbee==0.16.2", + "zigpy-deconz==0.20.0", + "zigpy==0.54.0", + "zigpy-xbee==0.17.0", "zigpy-zigate==0.10.3", - "zigpy-znp==0.9.3" + "zigpy-znp==0.10.0" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 3cbd6bd365..8706e4e5f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -2710,19 +2710,19 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2b78b4541..4b1ce6ec33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,7 +355,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -1938,19 +1938,19 @@ zeversolar==0.3.1 zha-quirks==0.0.95 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zwave_js zwave-js-server-python==0.47.1 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 392c589ea1..be53b22be6 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -323,3 +323,32 @@ async def test_gateway_initialize_bellows_thread( await zha_gateway.async_initialize() assert mock_new.mock_calls[0].args[0]["use_thread"] is thread_state + + +@pytest.mark.parametrize( + ("device_path", "config_override", "expected_channel"), + [ + ("/dev/ttyUSB0", {}, None), + ("socket://192.168.1.123:9999", {}, None), + ("socket://192.168.1.123:9999", {"network": {"channel": 20}}, 20), + ("socket://core-silabs-multiprotocol:9999", {}, 15), + ("socket://core-silabs-multiprotocol:9999", {"network": {"channel": 20}}, 20), + ], +) +async def test_gateway_force_multi_pan_channel( + device_path: str, + config_override: dict, + expected_channel: int | None, + hass: HomeAssistant, + coordinator, +) -> None: + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + zha_gateway = get_zha_gateway(hass) + assert zha_gateway is not None + + zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) + zha_gateway.config_entry.data["device"]["path"] = device_path + zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + + _, config = zha_gateway.get_application_controller_data() + assert config["network"]["channel"] == expected_channel From e7e2532c6897854c8a4a6ca0fa9e709d5c50e01f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 20:55:55 -0400 Subject: [PATCH 0369/1165] Bumped version to 2023.4.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index fba1d65459..b47e1d9fb5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 73d680092f..76c1f18616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b1" +version = "2023.4.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ed673a1b352add4dba73008c4669c68e86d3a34c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 21:05:56 -1000 Subject: [PATCH 0370/1165] Avoid creating a task on callback in owntracks when using mqtt (#90548) Nothing was being awaited in the callback. It did not need to be a coro --- homeassistant/components/owntracks/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 6086ee1efd..560493888d 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -24,6 +24,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_when_setup +from homeassistant.util.json import json_loads from .config_flow import CONF_SECRET from .const import DOMAIN @@ -133,10 +134,11 @@ async def async_connect_mqtt(hass, component): """Subscribe to MQTT topic.""" context = hass.data[DOMAIN]["context"] - async def async_handle_mqtt_message(msg): + @callback + def async_handle_mqtt_message(msg): """Handle incoming OwnTracks message.""" try: - message = json.loads(msg.payload) + message = json_loads(msg.payload) except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", msg.payload) From 2e0ecf9bd9a5414683f04d0334c4c2ed35d1ad9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 21:10:55 -1000 Subject: [PATCH 0371/1165] Avoid more task creation in the discovery helper (#90552) * Avoid more task creation in the discovery helper There is no longer a reason to awaiti the jobs being dispatched since nothing was using the result and there is no risk of job being garbage collected prematurely anymore since the task revamp * Update homeassistant/helpers/discovery.py --- homeassistant/helpers/discovery.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 824b1de701..b7db5ba69f 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -46,16 +46,15 @@ def async_listen( """ job = core.HassJob(callback, f"discovery listener {service}") - async def discovery_event_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_event_listener(discovered: DiscoveryDict) -> None: """Listen for discovery events.""" - task = hass.async_run_hass_job( - job, discovered["service"], discovered["discovered"] - ) - if task: - await task + hass.async_run_hass_job(job, discovered["service"], discovered["discovered"]) async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_event_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_event_listener, ) @@ -105,17 +104,17 @@ def async_listen_platform( service = EVENT_LOAD_PLATFORM.format(component) job = core.HassJob(callback, f"platform loaded {component}") - async def discovery_platform_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_platform_listener(discovered: DiscoveryDict) -> None: """Listen for platform discovery events.""" if not (platform := discovered["platform"]): return - - task = hass.async_run_hass_job(job, platform, discovered.get("discovered")) - if task: - await task + hass.async_run_hass_job(job, platform, discovered.get("discovered")) return async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_platform_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_platform_listener, ) From d0c38c1e12db38bc62d8286ced4fe1dd14b7ef2b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 09:34:17 +0200 Subject: [PATCH 0372/1165] Move icon constants to entity attributes (#90518) * Move icon constants to attribute * Adjust test --- homeassistant/components/currencylayer/sensor.py | 7 +------ .../components/dublin_bus_transport/sensor.py | 8 ++------ homeassistant/components/fixer/sensor.py | 10 ++-------- homeassistant/components/gitter/sensor.py | 8 ++------ .../homekit_controller/alarm_control_panel.py | 8 +------- .../components/irish_rail_transport/sensor.py | 8 ++------ homeassistant/components/lastfm/sensor.py | 7 +------ homeassistant/components/london_underground/sensor.py | 7 +------ .../components/nederlandse_spoorwegen/sensor.py | 7 +------ homeassistant/components/neurio_energy/sensor.py | 8 ++------ homeassistant/components/numato/sensor.py | 9 ++------- homeassistant/components/oasa_telematics/sensor.py | 8 ++------ homeassistant/components/otp/sensor.py | 7 +------ homeassistant/components/pocketcasts/sensor.py | 8 ++------ homeassistant/components/random/sensor.py | 8 ++------ homeassistant/components/rejseplanen/sensor.py | 8 ++------ homeassistant/components/simulated/sensor.py | 9 ++------- homeassistant/components/smappee/switch.py | 8 ++------ homeassistant/components/srp_energy/const.py | 4 +--- homeassistant/components/srp_energy/sensor.py | 10 ++-------- homeassistant/components/starlingbank/sensor.py | 9 +++------ .../components/swiss_public_transport/sensor.py | 7 +------ homeassistant/components/tmb/sensor.py | 8 +------- homeassistant/components/utility_meter/sensor.py | 7 +------ homeassistant/components/vasttrafik/sensor.py | 7 +------ homeassistant/components/xbox_live/sensor.py | 7 +------ homeassistant/components/yandex_transport/sensor.py | 8 ++------ homeassistant/components/zestimate/sensor.py | 7 +------ tests/components/srp_energy/test_sensor.py | 6 ++---- 29 files changed, 46 insertions(+), 177 deletions(-) diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 9905228c26..b4a3339289 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -20,7 +20,6 @@ _RESOURCE = "http://apilayer.net/api/live" DEFAULT_BASE = "USD" DEFAULT_NAME = "CurrencyLayer Sensor" -ICON = "mdi:currency" SCAN_INTERVAL = timedelta(hours=4) @@ -60,6 +59,7 @@ class CurrencylayerSensor(SensorEntity): """Implementing the Currencylayer sensor.""" _attr_attribution = "Data provided by currencylayer.com" + _attr_icon = "mdi:currency" def __init__(self, rest, base, quote): """Initialize the sensor.""" @@ -78,11 +78,6 @@ class CurrencylayerSensor(SensorEntity): """Return the name of the sensor.""" return self._base - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 60d058220a..b50bd60476 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -32,7 +32,7 @@ CONF_STOP_ID = "stopid" CONF_ROUTE = "route" DEFAULT_NAME = "Next Bus" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) TIME_STR_FORMAT = "%H:%M" @@ -77,6 +77,7 @@ class DublinPublicTransportSensor(SensorEntity): """Implementation of an Dublin public transport sensor.""" _attr_attribution = "Data provided by data.dublinked.ie" + _attr_icon = "mdi:bus" def __init__(self, data, stop, route, name): """Initialize the sensor.""" @@ -118,11 +119,6 @@ class DublinPublicTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from opendata.ch and update the states.""" self.data.update() diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 234f03812f..8091f8981e 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -19,12 +19,10 @@ _LOGGER = logging.getLogger(__name__) ATTR_EXCHANGE_RATE = "Exchange rate" ATTR_TARGET = "Target currency" -ATTRIBUTION = "Data provided by the European Central Bank (ECB)" DEFAULT_BASE = "USD" DEFAULT_NAME = "Exchange rate" -ICON = "mdi:currency-usd" SCAN_INTERVAL = timedelta(days=1) @@ -61,7 +59,8 @@ def setup_platform( class ExchangeRateSensor(SensorEntity): """Representation of a Exchange sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Data provided by the European Central Bank (ECB)" + _attr_icon = "mdi:currency-usd" def __init__(self, data, name, target): """Initialize the sensor.""" @@ -94,11 +93,6 @@ class ExchangeRateSensor(SensorEntity): ATTR_TARGET: self._target, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the states.""" self.data.update() diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 514cb9e0ad..db5b189d5e 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -23,7 +23,6 @@ ATTR_USERNAME = "username" DEFAULT_NAME = "Gitter messages" DEFAULT_ROOM = "home-assistant/home-assistant" -ICON = "mdi:message-cog" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -59,6 +58,8 @@ def setup_platform( class GitterSensor(SensorEntity): """Representation of a Gitter sensor.""" + _attr_icon = "mdi:message-cog" + def __init__(self, data, room, name, username): """Initialize the sensor.""" self._name = name @@ -93,11 +94,6 @@ class GitterSensor(SensorEntity): ATTR_MENTION: self._mention, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index a466d15db5..a741cf5492 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -27,8 +27,6 @@ from . import KNOWN_DEVICES from .connection import HKDevice from .entity import HomeKitEntity -ICON = "mdi:security" - CURRENT_STATE_MAP = { 0: STATE_ALARM_ARMED_HOME, 1: STATE_ALARM_ARMED_AWAY, @@ -72,6 +70,7 @@ async def async_setup_entry( class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): """Representation of a Homekit Alarm Control Panel.""" + _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -86,11 +85,6 @@ class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): CharacteristicsTypes.BATTERY_LEVEL, ] - @property - def icon(self) -> str: - """Return icon.""" - return ICON - @property def state(self) -> str: """Return the state of the device.""" diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 7ac30cc5a2..70b53b80d9 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -30,7 +30,7 @@ CONF_DIRECTION = "direction" CONF_STOPS_AT = "stops_at" DEFAULT_NAME = "Next Train" -ICON = "mdi:train" + SCAN_INTERVAL = timedelta(minutes=2) TIME_STR_FORMAT = "%H:%M" @@ -76,6 +76,7 @@ class IrishRailTransportSensor(SensorEntity): """Implementation of an irish rail public transport sensor.""" _attr_attribution = "Data provided by Irish Rail" + _attr_icon = "mdi:train" def __init__(self, data, station, direction, destination, stops_at, name): """Initialize the sensor.""" @@ -128,11 +129,6 @@ class IrishRailTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and update the states.""" self.data.update() diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 497ccf817b..70f4c22cad 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -26,7 +26,6 @@ STATE_NOT_SCROBBLING = "Not Scrobbling" CONF_USERS = "users" -ICON = "mdi:radio-fm" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -64,6 +63,7 @@ class LastfmSensor(SensorEntity): """A class for the Last.fm account.""" _attr_attribution = "Data provided by Last.fm" + _attr_icon = "mdi:radio-fm" def __init__(self, user, lastfm_api): """Initialize the sensor.""" @@ -127,8 +127,3 @@ class LastfmSensor(SensorEntity): def entity_picture(self): """Avatar of the user.""" return self._cover - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 2cad8e9a10..8217b3913a 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -26,7 +26,6 @@ DOMAIN = "london_underground" CONF_LINE = "line" -ICON = "mdi:subway" SCAN_INTERVAL = timedelta(seconds=30) @@ -100,6 +99,7 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Sensor that reads the status of a line from Tube Data.""" _attr_attribution = "Powered by TfL Open Data" + _attr_icon = "mdi:subway" def __init__(self, coordinator, name): """Initialize the London Underground sensor.""" @@ -116,11 +116,6 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Return the state of the sensor.""" return self.coordinator.data[self.name]["State"] - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def extra_state_attributes(self): """Return other details about the sensor state.""" diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 7f4fbdfae7..f0c782bc1b 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -26,7 +26,6 @@ CONF_TO = "to" CONF_VIA = "via" CONF_TIME = "time" -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -104,6 +103,7 @@ class NSDepartureSensor(SensorEntity): """Implementation of a NS Departure Sensor.""" _attr_attribution = "Data provided by NS" + _attr_icon = "mdi:train" def __init__(self, nsapi, name, departure, heading, via, time): """Initialize the sensor.""" @@ -121,11 +121,6 @@ class NSDepartureSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_value(self): """Return the next departure time.""" diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 52f6d1d722..a9023ffca2 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -33,7 +33,6 @@ DAILY_NAME = "Daily Energy Usage" ACTIVE_TYPE = "active" DAILY_TYPE = "daily" -ICON = "mdi:flash" MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=150) MIN_TIME_BETWEEN_ACTIVE_UPDATES = timedelta(seconds=10) @@ -140,6 +139,8 @@ class NeurioData: class NeurioEnergy(SensorEntity): """Implementation of a Neurio energy sensor.""" + _attr_icon = "mdi:flash" + def __init__(self, data, name, sensor_type, update_call): """Initialize the sensor.""" self._name = name @@ -172,11 +173,6 @@ class NeurioEnergy(SensorEntity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data, update state.""" self.update_sensor() diff --git a/homeassistant/components/numato/sensor.py b/homeassistant/components/numato/sensor.py index 4ac28e0761..44adb78e6a 100644 --- a/homeassistant/components/numato/sensor.py +++ b/homeassistant/components/numato/sensor.py @@ -23,8 +23,6 @@ from . import ( _LOGGER = logging.getLogger(__name__) -ICON = "mdi:gauge" - def setup_platform( hass: HomeAssistant, @@ -71,6 +69,8 @@ def setup_platform( class NumatoGpioAdc(SensorEntity): """Represents an ADC port of a Numato USB GPIO expander.""" + _attr_icon = "mdi:gauge" + def __init__(self, name, device_id, port, src_range, dst_range, dst_unit, api): """Initialize the sensor.""" self._name = name @@ -97,11 +97,6 @@ class NumatoGpioAdc(SensorEntity): """Return the unit the value is expressed in.""" return self._unit_of_measurement - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" try: diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 664ad033cf..b910964594 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -34,7 +34,7 @@ CONF_STOP_ID = "stop_id" CONF_ROUTE_ID = "route_id" DEFAULT_NAME = "OASA Telematics" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(seconds=60) @@ -67,6 +67,7 @@ class OASATelematicsSensor(SensorEntity): """Implementation of the OASA Telematics sensor.""" _attr_attribution = "Data retrieved from telematics.oasa.gr" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route_id, name): """Initialize the sensor.""" @@ -121,11 +122,6 @@ class OASATelematicsSensor(SensorEntity): ) return {k: v for k, v in params.items() if v} - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from OASA API and update the states.""" self.data.update() diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 499c9b129f..7c7c30df97 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -17,7 +17,6 @@ DEFAULT_NAME = "OTP Sensor" TIME_STEP = 30 # Default time step assumed by Google Authenticator -ICON = "mdi:update" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -44,6 +43,7 @@ async def async_setup_platform( class TOTPSensor(SensorEntity): """Representation of a TOTP sensor.""" + _attr_icon = "mdi:update" _attr_should_poll = False def __init__(self, name, token): @@ -76,8 +76,3 @@ class TOTPSensor(SensorEntity): def native_value(self): """Return the state of the sensor.""" return self._state - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 3962ae4c06..c541e2cc0f 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -16,7 +16,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ICON = "mdi:rss" SENSOR_NAME = "Pocketcasts unlistened episodes" @@ -48,6 +47,8 @@ def setup_platform( class PocketCastsSensor(SensorEntity): """Representation of a pocket casts sensor.""" + _attr_icon = "mdi:rss" + def __init__(self, api): """Initialize the sensor.""" self._api = api @@ -63,11 +64,6 @@ class PocketCastsSensor(SensorEntity): """Return the sensor state.""" return self._state - @property - def icon(self): - """Return the icon for the sensor.""" - return ICON - def update(self) -> None: """Update sensor values.""" try: diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 19cf403eab..d4db30fd61 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "Random Sensor" DEFAULT_MIN = 0 DEFAULT_MAX = 20 -ICON = "mdi:hanger" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -54,6 +53,8 @@ async def async_setup_platform( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" + _attr_icon = "mdi:hanger" + def __init__(self, name, minimum, maximum, unit_of_measurement): """Initialize the Random sensor.""" self._name = name @@ -72,11 +73,6 @@ class RandomSensor(SensorEntity): """Return the state of the device.""" return self._state - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 9db7c6ff10..135205aa95 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -42,7 +42,7 @@ CONF_DIRECTION = "direction" CONF_DEPARTURE_TYPE = "departure_type" DEFAULT_NAME = "Next departure" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -98,6 +98,7 @@ class RejseplanenTransportSensor(SensorEntity): """Implementation of Rejseplanen transport sensor.""" _attr_attribution = "Data provided by rejseplanen.dk" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route, direction, name): """Initialize the sensor.""" @@ -143,11 +144,6 @@ class RejseplanenTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from rejseplanen.dk and update the states.""" self.data.update() diff --git a/homeassistant/components/simulated/sensor.py b/homeassistant/components/simulated/sensor.py index f2e64655ac..0f9db48e78 100644 --- a/homeassistant/components/simulated/sensor.py +++ b/homeassistant/components/simulated/sensor.py @@ -34,8 +34,6 @@ DEFAULT_SEED = 999 DEFAULT_UNIT = "value" DEFAULT_RELATIVE_TO_EPOCH = True -ICON = "mdi:chart-line" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), @@ -79,6 +77,8 @@ def setup_platform( class SimulatedSensor(SensorEntity): """Class for simulated sensor.""" + _attr_icon = "mdi:chart-line" + def __init__( self, name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch ): @@ -135,11 +135,6 @@ class SimulatedSensor(SensorEntity): """Return the state of the sensor.""" return self._state - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index b179daaf1a..828e4a6812 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -10,7 +10,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN SWITCH_PREFIX = "Switch" -ICON = "mdi:toggle-switch" async def async_setup_entry( @@ -55,6 +54,8 @@ async def async_setup_entry( class SmappeeActuator(SwitchEntity): """Representation of a Smappee Comport Plug.""" + _attr_icon = "mdi:toggle-switch" + def __init__( self, smappee_base, @@ -105,11 +106,6 @@ class SmappeeActuator(SwitchEntity): # Switch or comfort plug return self._state == "ON_ON" - @property - def icon(self): - """Icon to use in the frontend.""" - return ICON - def turn_on(self, **kwargs: Any) -> None: """Turn on Comport Plug.""" if self._actuator_type in ("SWITCH", "COMFORT_PLUG"): diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index 527a1ed78b..cbc7078616 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -6,10 +6,8 @@ DEFAULT_NAME = "SRP Energy" CONF_IS_TOU = "is_tou" -ATTRIBUTION = "Powered by SRP Energy" + MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) SENSOR_NAME = "Usage" SENSOR_TYPE = "usage" - -ICON = "mdi:flash" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 1aaf5175e5..a919bba1b2 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -17,9 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - ATTRIBUTION, DEFAULT_NAME, - ICON, MIN_TIME_BETWEEN_UPDATES, SENSOR_NAME, SENSOR_TYPE, @@ -83,7 +81,8 @@ async def async_setup_entry( class SrpEntity(SensorEntity): """Implementation of a Srp Energy Usage sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Powered by SRP Energy" + _attr_icon = "mdi:flash" _attr_should_poll = False def __init__(self, coordinator): @@ -116,11 +115,6 @@ class SrpEntity(SensorEntity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def icon(self): - """Return icon.""" - return ICON - @property def usage(self): """Return entity state.""" diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 350c420d5d..f4a8783787 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -26,7 +26,7 @@ CONF_SANDBOX = "sandbox" DEFAULT_SANDBOX = False DEFAULT_ACCOUNT_NAME = "Starling" -ICON = "mdi:currency-gbp" + SCAN_INTERVAL = timedelta(seconds=180) ACCOUNT_SCHEMA = vol.Schema( @@ -76,6 +76,8 @@ def setup_platform( class StarlingBalanceSensor(SensorEntity): """Representation of a Starling balance sensor.""" + _attr_icon = "mdi:currency-gbp" + def __init__(self, starling_account, account_name, balance_data_type): """Initialize the sensor.""" self._starling_account = starling_account @@ -100,11 +102,6 @@ class StarlingBalanceSensor(SensorEntity): """Return the unit of measurement.""" return self._starling_account.currency - @property - def icon(self): - """Return the entity icon.""" - return ICON - def update(self) -> None: """Fetch new state data for the sensor.""" self._starling_account.update_balance_data() diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 8735726f89..12007e1741 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -35,7 +35,6 @@ CONF_START = "from" DEFAULT_NAME = "Next Departure" -ICON = "mdi:bus" SCAN_INTERVAL = timedelta(seconds=90) @@ -79,6 +78,7 @@ class SwissPublicTransportSensor(SensorEntity): """Implementation of an Swiss public transport sensor.""" _attr_attribution = "Data provided by transport.opendata.ch" + _attr_icon = "mdi:bus" def __init__(self, opendata, start, destination, name): """Initialize the sensor.""" @@ -125,11 +125,6 @@ class SwissPublicTransportSensor(SensorEntity): ATTR_DELAY: self._opendata.connections[0]["delay"], } - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - async def async_update(self) -> None: """Get the latest data from opendata.ch and update the states.""" diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index dd94b4c11b..7fe8630cc9 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -18,8 +18,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ICON = "mdi:bus-clock" - CONF_APP_ID = "app_id" CONF_APP_KEY = "app_key" CONF_LINE = "line" @@ -74,6 +72,7 @@ class TMBSensor(SensorEntity): """Implementation of a TMB line/stop Sensor.""" _attr_attribution = "Data provided by Transport Metropolitans de Barcelona" + _attr_icon = "mdi:bus-clock" def __init__(self, ibus_client, stop, line, name): """Initialize the sensor.""" @@ -89,11 +88,6 @@ class TMBSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index dad2d8dfaf..dd0fb685ba 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -94,7 +94,6 @@ DEVICE_CLASS_MAP = { UnitOfEnergy.KILO_WATT_HOUR: SensorDeviceClass.ENERGY, } -ICON = "mdi:counter" PRECISION = 3 PAUSED = "paused" @@ -323,6 +322,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): class UtilityMeterSensor(RestoreSensor): """Representation of an utility meter sensor.""" + _attr_icon = "mdi:counter" _attr_should_poll = False def __init__( @@ -659,11 +659,6 @@ class UtilityMeterSensor(RestoreSensor): return state_attr - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def extra_restore_state_data(self) -> UtilitySensorExtraStoredData: """Return sensor specific state data to be restored.""" diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 118d04d3c1..711f66ea03 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -32,7 +32,6 @@ CONF_SECRET = "secret" DEFAULT_DELAY = 0 -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -83,6 +82,7 @@ class VasttrafikDepartureSensor(SensorEntity): """Implementation of a Vasttrafik Departure Sensor.""" _attr_attribution = "Data provided by Västtrafik" + _attr_icon = "mdi:train" def __init__(self, planner, name, departure, heading, lines, delay): """Initialize the sensor.""" @@ -110,11 +110,6 @@ class VasttrafikDepartureSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py index d95031a646..2ad3f75468 100644 --- a/homeassistant/components/xbox_live/sensor.py +++ b/homeassistant/components/xbox_live/sensor.py @@ -20,7 +20,6 @@ _LOGGER = logging.getLogger(__name__) CONF_XUID = "xuid" -ICON = "mdi:microsoft-xbox" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -98,6 +97,7 @@ def get_user_gamercard(api, xuid): class XboxSensor(SensorEntity): """A class for the Xbox account.""" + _attr_icon = "mdi:microsoft-xbox" _attr_should_poll = False def __init__(self, api, xuid, gamercard, interval): @@ -138,11 +138,6 @@ class XboxSensor(SensorEntity): """Avatar of the account.""" return self._picture - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON - async def async_added_to_hass(self) -> None: """Start custom polling.""" diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index d3e7e48815..1fbae6c88a 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -29,7 +29,7 @@ CONF_STOP_ID = "stop_id" CONF_ROUTE = "routes" DEFAULT_NAME = "Yandex Transport" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -70,6 +70,7 @@ class DiscoverYandexTransport(SensorEntity): """Implementation of yandex_transport sensor.""" _attr_attribution = "Data provided by maps.yandex.ru" + _attr_icon = "mdi:bus" def __init__(self, requester: YandexMapsRequester, stop_id, routes, name) -> None: """Initialize sensor.""" @@ -168,8 +169,3 @@ class DiscoverYandexTransport(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" return self._attrs - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 3c6b7c7186..9b520c4681 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "Zestimate" NAME = "zestimate" ZESTIMATE = f"{DEFAULT_NAME}:{NAME}" -ICON = "mdi:home-variant" ATTR_AMOUNT = "amount" ATTR_CHANGE = "amount_change_30_days" @@ -67,6 +66,7 @@ class ZestimateDataSensor(SensorEntity): """Implementation of a Zestimate sensor.""" _attr_attribution = "Data provided by Zillow.com" + _attr_icon = "mdi:home-variant" def __init__(self, name, params): """Initialize the sensor.""" @@ -103,11 +103,6 @@ class ZestimateDataSensor(SensorEntity): attributes["address"] = self.address return attributes - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self): """Get the latest data and update the states.""" diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 4493088606..0f59474ffb 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -3,9 +3,7 @@ from unittest.mock import MagicMock from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.srp_energy.const import ( - ATTRIBUTION, DEFAULT_NAME, - ICON, SENSOR_NAME, SENSOR_TYPE, SRP_ENERGY_DOMAIN, @@ -91,10 +89,10 @@ async def test_srp_entity(hass: HomeAssistant) -> None: assert srp_entity.unique_id == SENSOR_TYPE assert srp_entity.state is None assert srp_entity.unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR - assert srp_entity.icon == ICON + assert srp_entity.icon == "mdi:flash" assert srp_entity.usage == "2.00" assert srp_entity.should_poll is False - assert srp_entity.attribution == ATTRIBUTION + assert srp_entity.attribution == "Powered by SRP Energy" assert srp_entity.available is not None assert srp_entity.device_class is SensorDeviceClass.ENERGY assert srp_entity.state_class is SensorStateClass.TOTAL_INCREASING From b3887a633d2f57458687c2fee3503f0656d0205f Mon Sep 17 00:00:00 2001 From: Hans Oischinger Date: Fri, 31 Mar 2023 09:44:30 +0200 Subject: [PATCH 0373/1165] Bump PyVicare to 2.25.0 (#90536) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index f031e7a131..ae578492a1 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.21.0"] + "requirements": ["PyViCare==2.25.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8706e4e5f9..2edc33de9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -50,7 +50,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.6.7 # homeassistant.components.vicare -PyViCare==2.21.0 +PyViCare==2.25.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 01b780f4cf..1a29f26409 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -46,7 +46,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.6.7 # homeassistant.components.vicare -PyViCare==2.21.0 +PyViCare==2.25.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From b9f0701336221569a503f593d0a25a5eb35bfae1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 11:43:28 +0200 Subject: [PATCH 0374/1165] Update ruff to v0.0.260 (#90566) --- .pre-commit-config.yaml | 2 +- homeassistant/components/mysensors/light.py | 4 ++-- requirements_test_pre_commit.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd196f19db..ffdd8904af 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.256 + rev: v0.0.260 hooks: - id: ruff args: diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 68f8bb566f..213e268696 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -172,7 +172,7 @@ class MySensorsLightRGB(MySensorsLight): new_rgb: tuple[int, int, int] | None = kwargs.get(ATTR_RGB_COLOR) if new_rgb is None: return - hex_color = "%02x%02x%02x" % new_rgb + hex_color = "{:02x}{:02x}{:02x}".format(*new_rgb) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) @@ -219,7 +219,7 @@ class MySensorsLightRGBW(MySensorsLightRGB): new_rgbw: tuple[int, int, int, int] | None = kwargs.get(ATTR_RGBW_COLOR) if new_rgbw is None: return - hex_color = "%02x%02x%02x%02x" % new_rgbw + hex_color = "{:02x}{:02x}{:02x}{:02x}".format(*new_rgbw) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index a1faadfea4..c46ed5e4e2 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -4,5 +4,5 @@ bandit==1.7.4 black==23.1.0 codespell==2.2.2 isort==5.12.0 -ruff==0.0.256 +ruff==0.0.260 yamllint==1.28.0 From b24a5750c39b6f36dee0027dad027dde586615df Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 12:19:45 +0200 Subject: [PATCH 0375/1165] Add CI timeout to codecov job (#90572) --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8464b1a299..03a6f2da92 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1091,6 +1091,7 @@ jobs: needs: - info - pytest + timeout-minutes: 10 steps: - name: Check out code from GitHub uses: actions/checkout@v3.5.0 From 6bad5f02c6b2cd1edf37129330c58ebad6a59546 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 12:20:08 +0200 Subject: [PATCH 0376/1165] Update black to 23.3.0 (#90569) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ffdd8904af..88bb4a703a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: args: - --fix - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index c46ed5e4e2..d76382c5c9 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,7 +1,7 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit bandit==1.7.4 -black==23.1.0 +black==23.3.0 codespell==2.2.2 isort==5.12.0 ruff==0.0.260 From c7e8fc9f9d365a395ae2bbc5e4e83ca50a2978fd Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 31 Mar 2023 12:38:23 +0200 Subject: [PATCH 0377/1165] Use more meaningful states for snapcast groups and clients (#77449) * Show muted snapcast groups as idle and use playing/idle state instead of on state for clients * New module constant STREAM_STATUS * Fix return type hint in snapcast --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/snapcast/media_player.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 6f965155bb..4fd7c587d4 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -42,6 +42,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT): cv.port} ) +STREAM_STATUS = { + "idle": MediaPlayerState.IDLE, + "playing": MediaPlayerState.PLAYING, + "unknown": None, +} + def register_services(): """Register snapcast services.""" @@ -157,11 +163,9 @@ class SnapcastGroupDevice(MediaPlayerEntity): @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" - return { - "idle": MediaPlayerState.IDLE, - "playing": MediaPlayerState.PLAYING, - "unknown": None, - }.get(self._group.stream_status) + if self.is_volume_muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._group.stream_status) @property def unique_id(self): @@ -289,11 +293,13 @@ class SnapcastClientDevice(MediaPlayerEntity): return list(self._client.group.streams_by_name().keys()) @property - def state(self) -> MediaPlayerState: + def state(self) -> MediaPlayerState | None: """Return the state of the player.""" if self._client.connected: - return MediaPlayerState.ON - return MediaPlayerState.OFF + if self.is_volume_muted or self._client.group.muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._client.group.stream_status) + return MediaPlayerState.STANDBY @property def extra_state_attributes(self): From 6153f17155f0d54a1bb5c4e87123e305042851d4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 13:58:53 +0200 Subject: [PATCH 0378/1165] Update sentry-sdk to 1.18.0 (#90571) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 95eff4e7a5..066549e262 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.16.0"] + "requirements": ["sentry-sdk==1.18.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2edc33de9b..2aab38d76a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2325,7 +2325,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.16.0 +sentry-sdk==1.18.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a29f26409..7779f3a5f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1661,7 +1661,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.16.0 +sentry-sdk==1.18.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From 28736e2ce430e3d6d40ad21848e9fd3a3768ac40 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 13:59:49 +0200 Subject: [PATCH 0379/1165] Update orjson to 3.8.9 (#90570) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 342942f0dd..a7815bdf91 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ ifaddr==0.1.7 janus==1.0.0 jinja2==3.1.2 lru-dict==1.1.8 -orjson==3.8.7 +orjson==3.8.9 paho-mqtt==1.6.1 pillow==9.4.0 pip>=21.0,<23.1 diff --git a/pyproject.toml b/pyproject.toml index d409ef188d..fbdee78422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==40.0.1", # pyOpenSSL 23.1.0 is required to work with cryptography 39+ "pyOpenSSL==23.1.0", - "orjson==3.8.7", + "orjson==3.8.9", "pip>=21.0,<23.1", "python-slugify==4.0.1", "pyyaml==6.0", diff --git a/requirements.txt b/requirements.txt index 84726cb49d..2dd936f706 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.1.8 PyJWT==2.6.0 cryptography==40.0.1 pyOpenSSL==23.1.0 -orjson==3.8.7 +orjson==3.8.9 pip>=21.0,<23.1 python-slugify==4.0.1 pyyaml==6.0 From 2e26b6e0ccb2cbf059aefcb5cf1f4f449ad66187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timm=20Sch=C3=A4uble?= Date: Fri, 31 Mar 2023 14:10:12 +0200 Subject: [PATCH 0380/1165] Add attachments to simplepush (#81033) * Add attachments * Fix looking for attachment keywords in values * Improve attachment input format * Implement better approach to attachment parsing * Make ruff happy * Adjust attachment format and implementation according to comment from emontnemery --- homeassistant/components/simplepush/const.py | 1 + homeassistant/components/simplepush/notify.py | 34 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/simplepush/const.py b/homeassistant/components/simplepush/const.py index 6195a5fd1d..101e7cb35f 100644 --- a/homeassistant/components/simplepush/const.py +++ b/homeassistant/components/simplepush/const.py @@ -6,6 +6,7 @@ DOMAIN: Final = "simplepush" DEFAULT_NAME: Final = "simplepush" DATA_HASS_CONFIG: Final = "simplepush_hass_config" +ATTR_ATTACHMENTS: Final = "attachments" ATTR_ENCRYPTED: Final = "encrypted" ATTR_EVENT: Final = "event" diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index b1c2eb5680..3e7fad8863 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN +from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN # Configuring Simplepush under the notify has been removed in 2022.9.0 PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA @@ -61,11 +61,34 @@ class SimplePushNotificationService(BaseNotificationService): """Send a message to a Simplepush user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + attachments = None # event can now be passed in the service data event = None if data := kwargs.get(ATTR_DATA): event = data.get(ATTR_EVENT) + attachments_data = data.get(ATTR_ATTACHMENTS) + if isinstance(attachments_data, list): + attachments = [] + for attachment in attachments_data: + if not ( + isinstance(attachment, dict) + and ( + "image" in attachment + or "video" in attachment + or ("video" in attachment and "thumbnail" in attachment) + ) + ): + _LOGGER.error("Attachment format is incorrect") + return + + if "video" in attachment and "thumbnail" in attachment: + attachments.append(attachment) + elif "video" in attachment: + attachments.append(attachment["video"]) + elif "image" in attachment: + attachments.append(attachment["image"]) + # use event from config until YAML config is removed event = event or self._event @@ -77,10 +100,17 @@ class SimplePushNotificationService(BaseNotificationService): salt=self._salt, title=title, message=message, + attachments=attachments, event=event, ) else: - send(key=self._device_key, title=title, message=message, event=event) + send( + key=self._device_key, + title=title, + message=message, + attachments=attachments, + event=event, + ) except BadRequest: _LOGGER.error("Bad request. Title or message are too long") From ab699d17a50acf14ca0183e7cf5a5ecbd4572a5f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:12:51 +0200 Subject: [PATCH 0381/1165] Ensure numeric sensors have a valid value (#85605) * Ensure numeric sensors have a valid value * Flake8 --- homeassistant/components/sensor/__init__.py | 37 +++++---------------- tests/components/sensor/test_init.py | 16 ++++----- 2 files changed, 16 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 4f56be77a9..d0fdc8a088 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -167,7 +167,6 @@ class SensorEntity(Entity): _attr_unit_of_measurement: None = ( None # Subclasses of SensorEntity should not set this ) - _invalid_numeric_value_reported = False _invalid_state_class_reported = False _invalid_unit_of_measurement_reported = False _last_reset_reported = False @@ -463,7 +462,7 @@ class SensorEntity(Entity): @final @property - def state(self) -> Any: # noqa: C901 + def state(self) -> Any: """Return the state of the sensor and perform unit conversions, if needed.""" native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement @@ -581,33 +580,13 @@ class SensorEntity(Entity): else: numerical_value = float(value) # type:ignore[arg-type] except (TypeError, ValueError) as err: - # Raise if precision is not None, for other cases log a warning - if suggested_precision is not None: - raise ValueError( - f"Sensor {self.entity_id} has device class {device_class}, " - f"state class {state_class} unit {unit_of_measurement} and " - f"suggested precision {suggested_precision} thus indicating it " - f"has a numeric value; however, it has the non-numeric value: " - f"{value} ({type(value)})" - ) from err - # This should raise in Home Assistant Core 2023.4 - if not self._invalid_numeric_value_reported: - self._invalid_numeric_value_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - "Sensor %s has device class %s, state class %s and unit %s " - "thus indicating it has a numeric value; however, it has the " - "non-numeric value: %s (%s); Please update your configuration " - "if your entity is manually configured, otherwise %s", - self.entity_id, - device_class, - state_class, - unit_of_measurement, - value, - type(value), - report_issue, - ) - return value + raise ValueError( + f"Sensor {self.entity_id} has device class {device_class}, " + f"state class {state_class} unit {unit_of_measurement} and " + f"suggested precision {suggested_precision} thus indicating it " + f"has a numeric value; however, it has the non-numeric value: " + f"{value} ({type(value)})" + ) from err else: numerical_value = value diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 8be15f1c7c..82ea25b5a1 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1803,20 +1803,20 @@ async def test_device_classes_with_invalid_unit_of_measurement( ], ) @pytest.mark.parametrize( - ("native_value", "expected"), + "native_value", [ - ("abc", "abc"), - ("13.7.1", "13.7.1"), - (datetime(2012, 11, 10, 7, 35, 1), "2012-11-10 07:35:01"), - (date(2012, 11, 10), "2012-11-10"), + "", + "abc", + "13.7.1", + datetime(2012, 11, 10, 7, 35, 1), + date(2012, 11, 10), ], ) -async def test_non_numeric_validation_warn( +async def test_non_numeric_validation_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, native_value: Any, - expected: str, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit: str | None, @@ -1837,7 +1837,7 @@ async def test_non_numeric_validation_warn( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == expected + assert state is None assert ( "thus indicating it has a numeric value; " From a616ac2b60c85ebf6d4416e31c0530ed2217e120 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:25:49 +0200 Subject: [PATCH 0382/1165] Move attribution constants to entity attributes (#90519) * Move attribution constants to entity attributes * Adjust meteo france * Adjust meteoclimatic * Adjust nws --- homeassistant/components/ampio/air_quality.py | 9 +++------ homeassistant/components/ampio/const.py | 1 - homeassistant/components/met/weather.py | 12 ++++-------- homeassistant/components/met_eireann/const.py | 2 -- .../components/met_eireann/weather.py | 8 ++------ .../components/meteo_france/weather.py | 6 +----- .../components/meteoclimatic/weather.py | 6 +----- homeassistant/components/nilu/air_quality.py | 8 ++------ .../components/norway_air/air_quality.py | 18 +++++++----------- homeassistant/components/nws/weather.py | 6 +----- .../components/opensensemap/air_quality.py | 8 ++------ 11 files changed, 23 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index f8119e9c1b..a423a62836 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -from .const import ATTRIBUTION, CONF_STATION_ID, SCAN_INTERVAL +from .const import CONF_STATION_ID, SCAN_INTERVAL _LOGGER: Final = logging.getLogger(__name__) @@ -54,6 +54,8 @@ async def async_setup_platform( class AmpioSmogQuality(AirQualityEntity): """Implementation of an Ampio Smog air quality entity.""" + _attr_attribution = "Data provided by Ampio" + def __init__( self, api: AmpioSmogMapData, station_id: str, name: str | None ) -> None: @@ -82,11 +84,6 @@ class AmpioSmogQuality(AirQualityEntity): """Return the particulate matter 10 level.""" return self._ampio.api.pm10 # type: ignore[no-any-return] - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self) -> None: """Get the latest data from the AmpioMap API.""" await self._ampio.async_update() diff --git a/homeassistant/components/ampio/const.py b/homeassistant/components/ampio/const.py index 3162308ff4..b1a13ce941 100644 --- a/homeassistant/components/ampio/const.py +++ b/homeassistant/components/ampio/const.py @@ -2,6 +2,5 @@ from datetime import timedelta from typing import Final -ATTRIBUTION: Final = "Data provided by Ampio" CONF_STATION_ID: Final = "station_id" SCAN_INTERVAL: Final = timedelta(minutes=10) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index f507cf8cf3..a6dcb23cc4 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -35,9 +35,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from . import MetDataUpdateCoordinator from .const import ATTR_MAP, CONDITIONS_MAP, CONF_TRACK_HOME, DOMAIN, FORECAST_MAP -ATTRIBUTION = ( - "Weather forecast from met.no, delivered by the Norwegian Meteorological Institute." -) DEFAULT_NAME = "Met.no" @@ -74,6 +71,10 @@ def format_condition(condition: str) -> str: class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Implementation of a Met.no weather condition.""" + _attr_attribution = ( + "Weather forecast from met.no, delivered by the Norwegian " + "Meteorological Institute." + ) _attr_has_entity_name = True _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -173,11 +174,6 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ATTR_MAP[ATTR_WEATHER_WIND_BEARING] ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" diff --git a/homeassistant/components/met_eireann/const.py b/homeassistant/components/met_eireann/const.py index efe80cb9d1..1cab9c9099 100644 --- a/homeassistant/components/met_eireann/const.py +++ b/homeassistant/components/met_eireann/const.py @@ -20,8 +20,6 @@ from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, ) -ATTRIBUTION = "Data provided by Met Éireann" - DEFAULT_NAME = "Met Éireann" DOMAIN = "met_eireann" diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index c4d8763efa..cce35731c7 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ATTRIBUTION, CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP +from .const import CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP _LOGGER = logging.getLogger(__name__) @@ -55,6 +55,7 @@ async def async_setup_entry( class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Implementation of a Met Éireann weather condition.""" + _attr_attribution = "Data provided by Met Éireann" _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -125,11 +126,6 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Return the wind direction.""" return self.coordinator.data.current_weather_data.get("wind_bearing") - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self): """Return the forecast array.""" diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 95972a95bb..e1a530eef9 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -83,6 +83,7 @@ class MeteoFranceWeather( ): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA @@ -203,8 +204,3 @@ class MeteoFranceWeather( } ) return forecast_data - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index 14b953663d..11346ab18f 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -38,6 +38,7 @@ async def async_setup_entry( class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR @@ -98,8 +99,3 @@ class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): def wind_bearing(self): """Return the wind bearing.""" return self.coordinator.data["weather"].wind_bearing - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index 5c3f9c5946..3745c6bae6 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -39,7 +39,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_AREA = "area" ATTR_POLLUTION_INDEX = "nilu_pollution_index" -ATTRIBUTION = "Data provided by luftkvalitet.info and nilu.no" CONF_AREA = "area" CONF_STATION = "stations" @@ -173,6 +172,8 @@ class NiluData: class NiluSensor(AirQualityEntity): """Single nilu station air sensor.""" + _attr_attribution = "Data provided by luftkvalitet.info and nilu.no" + def __init__(self, api_data: NiluData, name: str, show_on_map: bool) -> None: """Initialize the sensor.""" self._api = api_data @@ -184,11 +185,6 @@ class NiluSensor(AirQualityEntity): self._attrs[CONF_LATITUDE] = api_data.data.latitude self._attrs[CONF_LONGITUDE] = api_data.data.longitude - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index b4acdc3bdc..1a3d3661a1 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -17,12 +17,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = ( - "Air quality from " - "https://luftkvalitet.miljostatus.no/, " - "delivered by the Norwegian Meteorological Institute." -) -# https://api.met.no/license_data.html CONF_FORECAST = "forecast" @@ -81,6 +75,13 @@ def round_state(func): class AirSensor(AirQualityEntity): """Representation of an air quality sensor.""" + # https://api.met.no/license_data.html + _attr_attribution = ( + "Air quality from " + "https://luftkvalitet.miljostatus.no/, " + "delivered by the Norwegian Meteorological Institute." + ) + def __init__(self, name, coordinates, forecast, session): """Initialize the sensor.""" self._name = name @@ -88,11 +89,6 @@ class AirSensor(AirQualityEntity): coordinates, forecast, session, api_url=OVERRIDE_URL ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index ecb95a1f9e..9edf6e6175 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -108,6 +108,7 @@ if TYPE_CHECKING: class NWSWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_should_poll = False def __init__( @@ -154,11 +155,6 @@ class NWSWeather(WeatherEntity): self.async_write_ha_state() - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def name(self) -> str: """Return the name of the station.""" diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index 5999eb9158..0e918103cd 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -20,7 +20,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by openSenseMap" CONF_STATION_ID = "station_id" @@ -59,6 +58,8 @@ async def async_setup_platform( class OpenSenseMapQuality(AirQualityEntity): """Implementation of an openSenseMap air quality entity.""" + _attr_attribution = "Data provided by openSenseMap" + def __init__(self, name, osm): """Initialize the air quality entity.""" self._name = name @@ -79,11 +80,6 @@ class OpenSenseMapQuality(AirQualityEntity): """Return the particulate matter 10 level.""" return self._osm.api.pm10 - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self): """Get the latest data from the openSenseMap API.""" await self._osm.async_update() From 8cbe3940283043f583437d0994104fa9c5a0d77d Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Fri, 31 Mar 2023 15:27:37 +0300 Subject: [PATCH 0383/1165] Use `get_ha_sensor_data` method to update glances sensors (#83983) * Use `get_ha_sensor_data` method to update sensor state * update tests * Use `get_ha_sensor_data` to validate connection * Update test_sensor.py --------- Co-authored-by: Erik Montnemery --- .../components/glances/config_flow.py | 2 +- .../components/glances/coordinator.py | 3 +- homeassistant/components/glances/sensor.py | 231 ++++-------------- tests/components/glances/__init__.py | 97 +++++++- tests/components/glances/conftest.py | 7 +- tests/components/glances/test_config_flow.py | 2 +- tests/components/glances/test_init.py | 2 +- tests/components/glances/test_sensor.py | 69 ++++++ 8 files changed, 224 insertions(+), 189 deletions(-) create mode 100644 tests/components/glances/test_sensor.py diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index cf55118a91..04e133248a 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -45,7 +45,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect.""" api = get_api(hass, data) try: - await api.get_data("all") + await api.get_ha_sensor_data() except GlancesApiError as err: raise CannotConnect from err diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 8ffd2a2da6..01e498a889 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -36,7 +36,6 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Get the latest data from the Glances REST API.""" try: - await self.api.get_data("all") + return await self.api.get_ha_sensor_data() except exceptions.GlancesApiError as err: raise UpdateFailed from err - return self.api.data diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index b8b5d80a20..8b836fba3e 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -15,7 +15,6 @@ from homeassistant.const import ( CONF_NAME, PERCENTAGE, REVOLUTIONS_PER_MINUTE, - STATE_UNAVAILABLE, Platform, UnitOfInformation, UnitOfTemperature, @@ -45,8 +44,8 @@ class GlancesSensorEntityDescription( """Describe Glances sensor entity.""" -SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( - GlancesSensorEntityDescription( +SENSOR_TYPES = { + ("fs", "disk_use_percent"): GlancesSensorEntityDescription( key="disk_use_percent", type="fs", name_suffix="used percent", @@ -54,7 +53,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_use"): GlancesSensorEntityDescription( key="disk_use", type="fs", name_suffix="used", @@ -63,7 +62,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_free"): GlancesSensorEntityDescription( key="disk_free", type="fs", name_suffix="free", @@ -72,7 +71,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use_percent"): GlancesSensorEntityDescription( key="memory_use_percent", type="mem", name_suffix="RAM used percent", @@ -80,7 +79,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use"): GlancesSensorEntityDescription( key="memory_use", type="mem", name_suffix="RAM used", @@ -89,7 +88,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_free"): GlancesSensorEntityDescription( key="memory_free", type="mem", name_suffix="RAM free", @@ -98,7 +97,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use_percent"): GlancesSensorEntityDescription( key="swap_use_percent", type="memswap", name_suffix="Swap used percent", @@ -106,7 +105,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use"): GlancesSensorEntityDescription( key="swap_use", type="memswap", name_suffix="Swap used", @@ -115,7 +114,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_free"): GlancesSensorEntityDescription( key="swap_free", type="memswap", name_suffix="Swap free", @@ -124,42 +123,42 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("load", "processor_load"): GlancesSensorEntityDescription( key="processor_load", type="load", name_suffix="CPU load", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_running"): GlancesSensorEntityDescription( key="process_running", type="processcount", name_suffix="Running", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_total"): GlancesSensorEntityDescription( key="process_total", type="processcount", name_suffix="Total", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_thread"): GlancesSensorEntityDescription( key="process_thread", type="processcount", name_suffix="Thread", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_sleeping"): GlancesSensorEntityDescription( key="process_sleeping", type="processcount", name_suffix="Sleeping", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("cpu", "cpu_use_percent"): GlancesSensorEntityDescription( key="cpu_use_percent", type="cpu", name_suffix="CPU used", @@ -167,7 +166,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_core"): GlancesSensorEntityDescription( key="temperature_core", type="sensors", name_suffix="Temperature", @@ -175,7 +174,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_hdd"): GlancesSensorEntityDescription( key="temperature_hdd", type="sensors", name_suffix="Temperature", @@ -183,7 +182,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "fan_speed"): GlancesSensorEntityDescription( key="fan_speed", type="sensors", name_suffix="Fan speed", @@ -191,7 +190,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "battery"): GlancesSensorEntityDescription( key="battery", type="sensors", name_suffix="Charge", @@ -200,14 +199,14 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:battery", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_active"): GlancesSensorEntityDescription( key="docker_active", type="docker", name_suffix="Containers active", icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_cpu_use"): GlancesSensorEntityDescription( key="docker_cpu_use", type="docker", name_suffix="Containers CPU used", @@ -215,7 +214,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_memory_use"): GlancesSensorEntityDescription( key="docker_memory_use", type="docker", name_suffix="Containers RAM used", @@ -224,21 +223,21 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "used"): GlancesSensorEntityDescription( key="used", type="raid", name_suffix="Raid used", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "available"): GlancesSensorEntityDescription( key="available", type="raid", name_suffix="Raid available", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), -) +} async def async_setup_entry( @@ -266,64 +265,40 @@ async def async_setup_entry( entity_id, new_unique_id=f"{config_entry.entry_id}-{new_key}" ) - for description in SENSOR_TYPES: - if description.type == "fs": - # fs will provide a list of disks attached - for disk in coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {disk['mnt_point']} {description.name_suffix}", - f"{disk['mnt_point']}-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - disk["mnt_point"], - description, - ) - ) - elif description.type == "sensors": - # sensors will provide temp for different devices - for sensor in coordinator.data[description.type]: - if sensor["type"] == description.key: + for sensor_type, sensors in coordinator.data.items(): + if sensor_type in ["fs", "sensors", "raid"]: + for sensor_label, params in sensors.items(): + for param in params: + sensor_description = SENSOR_TYPES[(sensor_type, param)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {sensor['label']} {description.name_suffix}", - f"{sensor['label']}-{description.key}", + f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", + f"{sensor_label}-{sensor_description.key}", ) entities.append( GlancesSensor( coordinator, name, - sensor["label"], - description, + sensor_label, + sensor_description, ) ) - elif description.type == "raid": - for raid_device in coordinator.data[description.type]: + else: + for sensor in sensors: + sensor_description = SENSOR_TYPES[(sensor_type, sensor)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {raid_device} {description.name_suffix}", - f"{raid_device}-{description.key}", + f"{coordinator.host}-{name} {sensor_description.name_suffix}", + f"-{sensor_description.key}", ) entities.append( - GlancesSensor(coordinator, name, raid_device, description) + GlancesSensor( + coordinator, + name, + "", + sensor_description, + ) ) - elif coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {description.name_suffix}", - f"-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - "", - description, - ) - ) async_add_entities(entities) @@ -354,114 +329,10 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}" @property - def native_value(self) -> StateType: # noqa: C901 + def native_value(self) -> StateType: """Return the state of the resources.""" - if (value := self.coordinator.data) is None: - return None - state: StateType = None - if self.entity_description.type == "fs": - for var in value["fs"]: - if var["mnt_point"] == self._sensor_name_prefix: - disk = var - break - if self.entity_description.key == "disk_free": - try: - state = round(disk["free"] / 1024**3, 1) - except KeyError: - state = round( - (disk["size"] - disk["used"]) / 1024**3, - 1, - ) - elif self.entity_description.key == "disk_use": - state = round(disk["used"] / 1024**3, 1) - elif self.entity_description.key == "disk_use_percent": - state = disk["percent"] - elif self.entity_description.key == "battery": - for sensor in value["sensors"]: - if ( - sensor["type"] == "battery" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "fan_speed": - for sensor in value["sensors"]: - if ( - sensor["type"] == "fan_speed" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_core": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_core" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_hdd": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_hdd" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "memory_use_percent": - state = value["mem"]["percent"] - elif self.entity_description.key == "memory_use": - state = round(value["mem"]["used"] / 1024**2, 1) - elif self.entity_description.key == "memory_free": - state = round(value["mem"]["free"] / 1024**2, 1) - elif self.entity_description.key == "swap_use_percent": - state = value["memswap"]["percent"] - elif self.entity_description.key == "swap_use": - state = round(value["memswap"]["used"] / 1024**3, 1) - elif self.entity_description.key == "swap_free": - state = round(value["memswap"]["free"] / 1024**3, 1) - elif self.entity_description.key == "processor_load": - # Windows systems don't provide load details - try: - state = value["load"]["min15"] - except KeyError: - state = value["cpu"]["total"] - elif self.entity_description.key == "process_running": - state = value["processcount"]["running"] - elif self.entity_description.key == "process_total": - state = value["processcount"]["total"] - elif self.entity_description.key == "process_thread": - state = value["processcount"]["thread"] - elif self.entity_description.key == "process_sleeping": - state = value["processcount"]["sleeping"] - elif self.entity_description.key == "cpu_use_percent": - state = value["quicklook"]["cpu"] - elif self.entity_description.key == "docker_active": - count = 0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - count += 1 - state = count - except KeyError: - state = count - elif self.entity_description.key == "docker_cpu_use": - cpu_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - cpu_use += container["cpu"]["total"] - state = round(cpu_use, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.key == "docker_memory_use": - mem_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - mem_use += container["memory"]["usage"] - state = round(mem_use / 1024**2, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.type == "raid": - for raid_device, raid in value["raid"].items(): - if raid_device == self._sensor_name_prefix: - state = raid[self.entity_description.key] + value = self.coordinator.data[self.entity_description.type] - return state + if isinstance(value.get(self._sensor_name_prefix), dict): + return value[self._sensor_name_prefix][self.entity_description.key] + return value[self.entity_description.key] diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 4818e9258d..8c9394ae84 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -1,6 +1,8 @@ """Tests for Glances.""" -MOCK_USER_INPUT = { +from typing import Any + +MOCK_USER_INPUT: dict[str, Any] = { "host": "0.0.0.0", "username": "username", "password": "password", @@ -30,6 +32,85 @@ MOCK_DATA = { "key": "disk_name", }, ], + "docker": { + "containers": [ + { + "key": "name", + "name": "container1", + "Status": "running", + "cpu": {"total": 50.94973493230174}, + "cpu_percent": 50.94973493230174, + "memory": { + "usage": 1120321536, + "limit": 3976318976, + "rss": 480641024, + "cache": 580915200, + "max_usage": 1309597696, + }, + "memory_usage": 539406336, + }, + { + "key": "name", + "name": "container2", + "Status": "running", + "cpu": {"total": 26.23567931034483}, + "cpu_percent": 26.23567931034483, + "memory": { + "usage": 85139456, + "limit": 3976318976, + "rss": 33677312, + "cache": 35012608, + "max_usage": 87650304, + }, + "memory_usage": 50126848, + }, + ] + }, + "fs": [ + { + "device_name": "/dev/sda8", + "fs_type": "ext4", + "mnt_point": "/ssl", + "size": 511320748032, + "used": 32910458880, + "free": 457917374464, + "percent": 6.7, + "key": "mnt_point", + }, + { + "device_name": "/dev/sda8", + "fs_type": "ext4", + "mnt_point": "/media", + "size": 511320748032, + "used": 32910458880, + "free": 457917374464, + "percent": 6.7, + "key": "mnt_point", + }, + ], + "mem": { + "total": 3976318976, + "available": 2878337024, + "percent": 27.6, + "used": 1097981952, + "free": 2878337024, + "active": 567971840, + "inactive": 1679704064, + "buffers": 149807104, + "cached": 1334816768, + "shared": 1499136, + }, + "sensors": [ + { + "label": "cpu_thermal 1", + "value": 59, + "warning": None, + "critical": None, + "unit": "C", + "type": "temperature_core", + "key": "label", + } + ], "system": { "os_name": "Linux", "hostname": "fedora-35", @@ -40,3 +121,17 @@ MOCK_DATA = { }, "uptime": "3 days, 10:25:20", } + +HA_SENSOR_DATA: dict[str, Any] = { + "fs": { + "/ssl": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, + "/media": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, + }, + "sensors": {"cpu_thermal 1": {"temperature_core": 59}}, + "mem": { + "memory_use_percent": 27.6, + "memory_use": 1047.1, + "memory_free": 2745.0, + }, + "docker": {"docker_active": 2, "docker_cpu_use": 77.2, "docker_memory_use": 1149.6}, +} diff --git a/tests/components/glances/conftest.py b/tests/components/glances/conftest.py index d92d3cc33d..9f4590ab5e 100644 --- a/tests/components/glances/conftest.py +++ b/tests/components/glances/conftest.py @@ -3,13 +3,14 @@ from unittest.mock import AsyncMock, patch import pytest -from . import MOCK_DATA +from . import HA_SENSOR_DATA @pytest.fixture(autouse=True) def mock_api(): """Mock glances api.""" with patch("homeassistant.components.glances.Glances") as mock_api: - mock_api.return_value.get_data = AsyncMock(return_value=None) - mock_api.return_value.data.return_value = MOCK_DATA + mock_api.return_value.get_ha_sensor_data = AsyncMock( + return_value=HA_SENSOR_DATA + ) yield mock_api diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index ab64205505..187e319fe0 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -42,7 +42,7 @@ async def test_form(hass: HomeAssistant) -> None: async def test_form_cannot_connect(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test to return error if we cannot connect.""" - mock_api.return_value.get_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError result = await hass.config_entries.flow.async_init( glances.DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 944d9d55ae..546f57ac3d 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -29,7 +29,7 @@ async def test_conn_error(hass: HomeAssistant, mock_api: MagicMock) -> None: entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) entry.add_to_hass(hass) - mock_api.return_value.get_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py new file mode 100644 index 0000000000..e5aadc9215 --- /dev/null +++ b/tests/components/glances/test_sensor.py @@ -0,0 +1,69 @@ +"""Tests for glances sensors.""" +import pytest + +from homeassistant.components.glances.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import HA_SENSOR_DATA, MOCK_USER_INPUT + +from tests.common import MockConfigEntry + + +async def test_sensor_states(hass: HomeAssistant) -> None: + """Test sensor states are correctly collected from library.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + if state := hass.states.get("sensor.0_0_0_0_ssl_disk_use"): + assert state.state == HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] + + if state := hass.states.get("sensor.0_0_0_0_cpu_thermal_1"): + assert state.state == HA_SENSOR_DATA["sensors"]["cpu_thermal 1"] + + +@pytest.mark.parametrize( + ("object_id", "old_unique_id", "new_unique_id"), + [ + ( + "glances_ssl_used_percent", + "0.0.0.0-Glances /ssl used percent", + "/ssl-disk_use_percent", + ), + ( + "glances_cpu_thermal_1_temperature", + "0.0.0.0-Glances cpu_thermal 1 Temperature", + "cpu_thermal 1-temperature_core", + ), + ], +) +async def test_migrate_unique_id( + hass: HomeAssistant, object_id: str, old_unique_id: str, new_unique_id: str +): + """Test unique id migration.""" + old_config_data = {**MOCK_USER_INPUT, "name": "Glances"} + entry = MockConfigEntry(domain=DOMAIN, data=old_config_data) + entry.add_to_hass(hass) + + ent_reg = er.async_get(hass) + + entity: er.RegistryEntry = ent_reg.async_get_or_create( + suggested_object_id=object_id, + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + assert entity.unique_id == old_unique_id + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = ent_reg.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{entry.entry_id}-{new_unique_id}" From ea32cc5d9213e2a78cad302dd3102469a59de36b Mon Sep 17 00:00:00 2001 From: Hans Oischinger Date: Fri, 31 Mar 2023 14:33:58 +0200 Subject: [PATCH 0384/1165] Refactor vicare config_flow tests (#90568) * Refactor vicare config_flow tests * Address review comments * Remove unused parameters --- tests/components/vicare/__init__.py | 12 +- tests/components/vicare/conftest.py | 16 ++ .../vicare/snapshots/test_config_flow.ambr | 17 ++ tests/components/vicare/test_config_flow.py | 153 +++++++++--------- 4 files changed, 106 insertions(+), 92 deletions(-) create mode 100644 tests/components/vicare/conftest.py create mode 100644 tests/components/vicare/snapshots/test_config_flow.ambr diff --git a/tests/components/vicare/__init__.py b/tests/components/vicare/__init__.py index 66cbfdc1d2..9e59c52940 100644 --- a/tests/components/vicare/__init__.py +++ b/tests/components/vicare/__init__.py @@ -1,16 +1,6 @@ """Test for ViCare.""" from __future__ import annotations -from typing import Final - -from homeassistant.components.vicare.const import CONF_HEATING_TYPE -from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME - -ENTRY_CONFIG: Final[dict[str, str]] = { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - CONF_HEATING_TYPE: "auto", -} +MODULE = "homeassistant.components.vicare" MOCK_MAC = "B874241B7B9" diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py new file mode 100644 index 0000000000..2ecd4f4309 --- /dev/null +++ b/tests/components/vicare/conftest.py @@ -0,0 +1,16 @@ +"""Fixtures for ViCare integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from . import MODULE + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch(f"{MODULE}.async_setup_entry", return_value=True) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/vicare/snapshots/test_config_flow.ambr b/tests/components/vicare/snapshots/test_config_flow.ambr new file mode 100644 index 0000000000..e99eda8234 --- /dev/null +++ b/tests/components/vicare/snapshots/test_config_flow.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_form_dhcp + dict({ + 'client_id': '5678', + 'heating_type': 'auto', + 'password': '1234', + 'username': 'foo@bar.com', + }) +# --- +# name: test_user_create_entry + dict({ + 'client_id': '5678', + 'heating_type': 'auto', + 'password': '1234', + 'username': 'foo@bar.com', + }) +# --- diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 10b7861ef7..72fb8d0d0b 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -1,132 +1,123 @@ """Test the ViCare config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp from homeassistant.components.vicare.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType -from . import ENTRY_CONFIG, MOCK_MAC +from . import MOCK_MAC, MODULE from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +VALID_CONFIG = { + CONF_USERNAME: "foo@bar.com", + CONF_PASSWORD: "1234", + CONF_CLIENT_ID: "5678", +} + +DHCP_INFO = dhcp.DhcpServiceInfo( + ip="1.1.1.1", + hostname="mock_hostname", + macaddress=MOCK_MAC, +) + + +async def test_user_create_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion +) -> None: + """Test that the user step works.""" + # start user flow result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert len(result["errors"]) == 0 + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + # test PyViCareInvalidCredentialsError with patch( - "homeassistant.components.vicare.config_flow.vicare_login", - return_value=None, - ), patch( - "homeassistant.components.vicare.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result2["title"] == "ViCare" - assert result2["data"] == ENTRY_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_invalid_login(hass: HomeAssistant) -> None: - """Test a flow with an invalid Vicare login.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.vicare.config_flow.vicare_login", + f"{MODULE}.config_flow.vicare_login", side_effect=PyViCareInvalidCredentialsError, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # test success + with patch( + f"{MODULE}.config_flow.vicare_login", + return_value=None, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "ViCare" + assert result["data"] == snapshot + mock_setup_entry.assert_called_once() -async def test_form_dhcp(hass: HomeAssistant) -> None: +async def test_form_dhcp( + hass: HomeAssistant, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion +) -> None: """Test we can setup from dhcp.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip="1.1.1.1", - hostname="mock_hostname", - macaddress=MOCK_MAC, - ), + context={"source": SOURCE_DHCP}, + data=DHCP_INFO, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} with patch( - "homeassistant.components.vicare.config_flow.vicare_login", + f"{MODULE}.config_flow.vicare_login", return_value=None, - ), patch( - "homeassistant.components.vicare.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( + ): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, + VALID_CONFIG, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result2["title"] == "ViCare" - assert result2["data"] == ENTRY_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "ViCare" + assert result["data"] == snapshot + mock_setup_entry.assert_called_once() async def test_dhcp_single_instance_allowed(hass: HomeAssistant) -> None: """Test that configuring more than one instance is rejected.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=ENTRY_CONFIG, + data=VALID_CONFIG, ) mock_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip="1.1.1.1", - hostname="mock_hostname", - macaddress=MOCK_MAC, - ), + context={"source": SOURCE_DHCP}, + data=DHCP_INFO, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" @@ -135,12 +126,12 @@ async def test_user_input_single_instance_allowed(hass: HomeAssistant) -> None: mock_entry = MockConfigEntry( domain=DOMAIN, unique_id="ViCare", - data=ENTRY_CONFIG, + data=VALID_CONFIG, ) mock_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" From 4f54e33f670e33e618c443c6a5f76885d38b9db8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:34:20 +0200 Subject: [PATCH 0385/1165] Allow removal of sensor settings in scrape (#90412) * Allow removal of sensor settings in scrape * Adjust * Adjust * Add comment * Simplify * Simplify * Adjust * Don't allow empty string * Only allow None * Use default as None * Use sentinel "none" * Not needed * Adjust unit of measurement * Add translation keys for "none" * Use translations * Sort * Add enum and timestamp * Use translation references * Remove default and set suggested_values * Disallow enum device class * Adjust tests * Adjust _strip_sentinel --- .../components/scrape/config_flow.py | 39 +++- homeassistant/components/scrape/strings.json | 67 ++++++ tests/components/scrape/conftest.py | 13 +- tests/components/scrape/test_config_flow.py | 193 +++++++++++++++++- 4 files changed, 294 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 1e3635a010..3ca13e56b2 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -95,6 +95,8 @@ RESOURCE_SETUP = { vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } +NONE_SENTINEL = "none" + SENSOR_SETUP = { vol.Required(CONF_SELECT): TextSelector(), vol.Optional(CONF_INDEX, default=0): NumberSelector( @@ -102,28 +104,45 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + vol.Required(CONF_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorDeviceClass], + options=[NONE_SENTINEL] + + sorted( + [ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ] + ), mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class", ) ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( + vol.Required(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], + options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), mode=SelectSelectorMode.DROPDOWN, + translation_key="state_class", ) ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], + options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), custom_value=True, mode=SelectSelectorMode.DROPDOWN, + translation_key="unit_of_measurement", ) ), } +def _strip_sentinel(options: dict[str, Any]) -> None: + """Convert sentinel to None.""" + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if options[key] == NONE_SENTINEL: + options.pop(key) + + async def validate_rest_setup( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -150,6 +169,7 @@ async def validate_sensor_setup( # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) + _strip_sentinel(user_input) sensors.append(user_input) return {} @@ -181,7 +201,11 @@ async def get_edit_sensor_suggested_values( ) -> dict[str, Any]: """Return suggested values for sensor editing.""" idx: int = handler.flow_state["_idx"] - return cast(dict[str, Any], handler.options[SENSOR_DOMAIN][idx]) + suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx]) + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if not suggested_values.get(key): + suggested_values[key] = NONE_SENTINEL + return suggested_values async def validate_sensor_edit( @@ -194,6 +218,7 @@ async def validate_sensor_edit( # In this case, we want to add a sub-item so we update the options directly. idx: int = handler.flow_state["_idx"] handler.options[SENSOR_DOMAIN][idx].update(user_input) + _strip_sentinel(handler.options[SENSOR_DOMAIN][idx]) return {} diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 052ef22848..857d53eb52 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -125,5 +125,72 @@ } } } + }, + "selector": { + "device_class": { + "options": { + "none": "No device class", + "date": "[%key:component::sensor::entity_component::date::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "state_class": { + "options": { + "none": "No state class", + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + }, + "unit_of_measurement": { + "options": { + "none": "No unit of measurement" + } + } } } diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py index 5ad4f39844..026daeea38 100644 --- a/tests/components/scrape/conftest.py +++ b/tests/components/scrape/conftest.py @@ -1,8 +1,9 @@ """Fixtures for the Scrape integration.""" from __future__ import annotations +from collections.abc import Generator from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid import pytest @@ -32,6 +33,16 @@ from . import MockRestData from tests.common import MockConfigEntry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Automatically path uuid generator.""" + with patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="get_config") async def get_config_to_integration_load() -> dict[str, Any]: """Return default minimal configuration. diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index e508937fed..9c6c5e0b4d 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -1,13 +1,14 @@ """Test the Scrape config flow.""" from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid from homeassistant import config_entries from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.scrape import DOMAIN +from homeassistant.components.scrape.config_flow import NONE_SENTINEL from homeassistant.components.scrape.const import ( CONF_ENCODING, CONF_INDEX, @@ -15,14 +16,18 @@ from homeassistant.components.scrape.const import ( DEFAULT_ENCODING, DEFAULT_VERIFY_SSL, ) +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_METHOD, CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, CONF_TIMEOUT, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant @@ -34,7 +39,9 @@ from . import MockRestData from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_form( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -46,10 +53,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ) as mock_data, patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_data: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -66,6 +70,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -92,7 +99,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_flow_fails( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test config flow error.""" result = await hass.config_entries.flow.async_init( @@ -137,9 +146,6 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ), patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -157,6 +163,9 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -278,6 +287,9 @@ async def test_options_add_remove_sensor_flow( CONF_NAME: "Template", CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -405,6 +417,9 @@ async def test_options_edit_sensor_flow( user_input={ CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -434,3 +449,161 @@ async def test_options_edit_sensor_flow( # Check the state of the entity has changed as expected state = hass.states.get("sensor.current_version") assert state.state == "Trying to get" + + +async def test_sensor_options_add_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } + + +async def test_sensor_options_remove_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } From 23372e8bc4629a1912e7615514b4f0d4a3e04982 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 14:55:48 +0200 Subject: [PATCH 0386/1165] Add arming/disarming state to Verisure (#90577) --- homeassistant/components/verisure/alarm_control_panel.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 0cfd6ebb81..9615404a9a 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -9,6 +9,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -83,18 +84,24 @@ class VerisureAlarm( async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" + self._attr_state = STATE_ALARM_DISARMING + self.async_write_ha_state() await self._async_set_arm_state( "DISARMED", self.coordinator.verisure.disarm(code) ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_HOME", self.coordinator.verisure.arm_home(code) ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_AWAY", self.coordinator.verisure.arm_away(code) ) From 1ca7f0dc6a6db63586d9fdffebc8cfc582d10c0e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:50:49 +0200 Subject: [PATCH 0387/1165] Tweak yalexs_ble translations (#90582) --- homeassistant/components/yalexs_ble/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index 0f1f138fd6..c2d1a2155c 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -22,7 +22,7 @@ } }, "error": { - "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and again.", + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", From 3467f4674e02630cd6f20fa9055a962a35f378fd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:53:35 +0200 Subject: [PATCH 0388/1165] Remove unnecessary calls to `async_update_entry` from `async_migrate_entry` (#90575) --- homeassistant/components/airvisual/__init__.py | 1 - homeassistant/components/ambient_station/__init__.py | 1 - homeassistant/components/axis/__init__.py | 1 - homeassistant/components/landisgyr_heat_meter/__init__.py | 1 - homeassistant/components/samsungtv/__init__.py | 1 - homeassistant/components/velbus/__init__.py | 2 -- 6 files changed, 7 deletions(-) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 793b787927..21be2e5d66 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -380,7 +380,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) else: entry.version = version - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 5dd8f0fb2f..f68ae3df11 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -117,7 +117,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: en_reg.async_clear_config_entry(entry.entry_id) version = entry.version = 2 - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index c4c05f1c51..65a425fa5c 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -51,7 +51,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.version != 3: # Home Assistant 2023.2 config_entry.version = 3 - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 3a44267bd4..0279af2e61 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -67,7 +67,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await async_migrate_entries( hass, config_entry.entry_id, update_entity_unique_id ) - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 0d90157f76..3406185b96 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -340,7 +340,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> en_reg.async_clear_config_entry(config_entry.entry_id) version = config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry) LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 554b16877c..b2b1cb3162 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -210,8 +210,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await hass.async_add_executor_job(shutil.rmtree, cache_path) # set the new version config_entry.version = 2 - # update the entry - hass.config_entries.async_update_entry(config_entry) _LOGGER.debug("Migration to version %s successful", config_entry.version) return True From 9a17c437ad7a94f71311670e6688f820f5d2ac39 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:59:48 +0200 Subject: [PATCH 0389/1165] Remove some dead code from google_assistant (#90581) --- homeassistant/components/google_assistant/trait.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index b248ffbac2..3752574f31 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -75,7 +75,6 @@ from homeassistant.util.percentage import ( from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( - CHALLENGE_ACK_NEEDED, CHALLENGE_FAILED_PIN_NEEDED, CHALLENGE_PIN_NEEDED, ERR_ALREADY_ARMED, @@ -2131,14 +2130,6 @@ def _verify_pin_challenge(data, state, challenge): raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) -def _verify_ack_challenge(data, state, challenge): - """Verify an ack challenge.""" - if not data.config.should_2fa(state): - return - if not challenge or not challenge.get("ack"): - raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) - - MEDIA_COMMAND_SUPPORT_MAPPING = { COMMAND_MEDIA_NEXT: media_player.SUPPORT_NEXT_TRACK, COMMAND_MEDIA_PAUSE: media_player.SUPPORT_PAUSE, From 8e77d215e7b6572cca86e8d554a02bf8406ebe87 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:02 +0200 Subject: [PATCH 0390/1165] Raise on invalid (dis)arm code in manual mqtt alarm (#90584) --- .../manual_mqtt/alarm_control_panel.py | 51 +++++++------------ .../manual_mqtt/test_alarm_control_panel.py | 20 +++++--- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index d6b4a58c41..fd6adb009a 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -345,56 +346,34 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_schedule_update_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -436,18 +415,22 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( from_state=self._state, to_state=state, parse_result=False ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 8aaccad105..549fa99517 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -24,6 +24,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -280,12 +281,13 @@ async def test_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -881,7 +883,8 @@ async def test_disarm_during_trigger_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1307,7 +1310,8 @@ async def test_disarm_with_template_code( state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From 469321157d67ce8afa8564a0b139a5a77f44034a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:16 +0200 Subject: [PATCH 0391/1165] Raise on invalid (dis)arm code in manual alarm (#90579) --- .../components/manual/alarm_control_panel.py | 51 +++++++------------ .../manual/test_alarm_control_panel.py | 23 ++++++--- 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index f0436ba1d6..da77aea6c4 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time @@ -285,56 +286,34 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -383,18 +362,22 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( parse_result=False, from_state=self._state, to_state=state ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 21cbc95d4e..f1a4b2da2e 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -26,6 +26,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -224,12 +225,16 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE + "2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + { + ATTR_ENTITY_ID: "alarm_control_panel.test", + ATTR_CODE: f"{CODE}2", + }, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -1082,7 +1087,8 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1125,7 +1131,8 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From 149e610bca452b20f099ac790473aee2175be00c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 17:03:02 +0200 Subject: [PATCH 0392/1165] Drop __eq__ dunder method from Entity (#90585) --- homeassistant/helpers/entity.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9d9e685d6a..eaac35286d 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -945,25 +945,6 @@ class Entity(ABC): self.entity_id = self.registry_entry.entity_id await self.platform.async_add_entities([self]) - def __eq__(self, other: Any) -> bool: - """Return the comparison.""" - if not isinstance(other, self.__class__): - return False - - # Can only decide equality if both have a unique id - if self.unique_id is None or other.unique_id is None: - return False - - # Ensure they belong to the same platform - if self.platform is not None or other.platform is not None: - if self.platform is None or other.platform is None: - return False - - if self.platform.platform != other.platform.platform: - return False - - return self.unique_id == other.unique_id - def __repr__(self) -> str: """Return the representation.""" return f"" From c566303edbfd2180d3c068aa776e65d8136eb21b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 06:23:05 -1000 Subject: [PATCH 0393/1165] Avoid writing state to all esphome entities at shutdown (#90555) --- homeassistant/components/esphome/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 192a19e480..58659a671f 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -349,7 +349,12 @@ async def async_setup_entry( # noqa: C901 # the next state update of that type when the device reconnects for state_keys in entry_data.state.values(): state_keys.clear() - entry_data.async_update_device_state(hass) + if not hass.is_stopping: + # Avoid marking every esphome entity as unavailable on shutdown + # since it generates a lot of state changed events and database + # writes when we already know we're shutting down and the state + # will be cleared anyway. + entry_data.async_update_device_state(hass) async def on_connect_error(err: Exception) -> None: """Start reauth flow if appropriate connect error type.""" From 03137feba5ef13772672eab51934c0bf21e74461 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Mar 2023 20:15:49 +0200 Subject: [PATCH 0394/1165] Update frontend to 20230331.0 (#90594) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6a2a904833..114760923e 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==20230330.0"] + "requirements": ["home-assistant-frontend==20230331.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a7815bdf91..ab00d9ca81 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 2aab38d76a..2906cda9a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7779f3a5f7..c1966555f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 611d4135fd8666ee3a0c8da773eb0acf66480fcd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 20:19:58 +0200 Subject: [PATCH 0395/1165] Add ComponentProtocol to improve type checking (#90586) --- homeassistant/config.py | 4 +- homeassistant/config_entries.py | 10 ++-- homeassistant/loader.py | 70 ++++++++++++++++++++--- homeassistant/setup.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 8 +++ 5 files changed, 77 insertions(+), 17 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 283f8726e2..0a5da91d94 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -61,7 +61,7 @@ from .helpers import ( ) from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType -from .loader import Integration, IntegrationNotFound +from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system @@ -681,7 +681,7 @@ def _log_pkg_error(package: str, component: str, config: dict, message: str) -> _LOGGER.error(message) -def _identify_config_schema(module: ModuleType) -> str | None: +def _identify_config_schema(module: ComponentProtocol) -> str | None: """Extract the schema and identify list or dict based.""" if not isinstance(module.CONFIG_SCHEMA, vol.Schema): return None diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 454cfeade2..3731f5fa9a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -383,7 +383,7 @@ class ConfigEntry: result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_setup_entry did not return boolean", integration.domain ) result = False @@ -546,8 +546,7 @@ class ConfigEntry: await self._async_process_on_unload() - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception as ex: # pylint: disable=broad-except _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain @@ -628,15 +627,14 @@ class ConfigEntry: try: result = await component.async_migrate_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_migrate_entry did not return boolean", self.domain ) return False if result: # pylint: disable-next=protected-access hass.config_entries._async_schedule_save() - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error migrating entry %s for %s", self.title, self.domain diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 600fef5a13..11f551b37e 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -15,13 +15,14 @@ import logging import pathlib import sys from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, TypeVar, cast from awesomeversion import ( AwesomeVersion, AwesomeVersionException, AwesomeVersionStrategy, ) +import voluptuous as vol from . import generated from .generated.application_credentials import APPLICATION_CREDENTIALS @@ -35,7 +36,10 @@ from .util.json import JSON_DECODE_EXCEPTIONS, json_loads # Typing imports that create a circular dependency if TYPE_CHECKING: + from .config_entries import ConfigEntry from .core import HomeAssistant + from .helpers import device_registry as dr + from .helpers.typing import ConfigType _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) @@ -260,6 +264,52 @@ async def async_get_config_flows( return flows +class ComponentProtocol(Protocol): + """Define the format of an integration.""" + + CONFIG_SCHEMA: vol.Schema + DOMAIN: str + + async def async_setup_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up a config entry.""" + + async def async_unload_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload a config entry.""" + + async def async_migrate_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Migrate an old config entry.""" + + async def async_remove_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> None: + """Remove a config entry.""" + + async def async_remove_config_entry_device( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + device_entry: dr.DeviceEntry, + ) -> bool: + """Remove a config entry device.""" + + async def async_reset_platform( + self, hass: HomeAssistant, integration_name: str + ) -> None: + """Release resources.""" + + async def async_setup(self, hass: HomeAssistant, config: ConfigType) -> bool: + """Set up integration.""" + + def setup(self, hass: HomeAssistant, config: ConfigType) -> bool: + """Set up integration.""" + + async def async_get_integration_descriptions( hass: HomeAssistant, ) -> dict[str, Any]: @@ -750,14 +800,18 @@ class Integration: return self._all_dependencies_resolved - def get_component(self) -> ModuleType: + def get_component(self) -> ComponentProtocol: """Return the component.""" - cache: dict[str, ModuleType] = self.hass.data.setdefault(DATA_COMPONENTS, {}) + cache: dict[str, ComponentProtocol] = self.hass.data.setdefault( + DATA_COMPONENTS, {} + ) if self.domain in cache: return cache[self.domain] try: - cache[self.domain] = importlib.import_module(self.pkg_path) + cache[self.domain] = cast( + ComponentProtocol, importlib.import_module(self.pkg_path) + ) except ImportError: raise except Exception as err: @@ -922,7 +976,7 @@ class CircularDependency(LoaderError): def _load_file( hass: HomeAssistant, comp_or_platform: str, base_paths: list[str] -) -> ModuleType | None: +) -> ComponentProtocol | None: """Try to load specified file. Looks in config dir first, then built-in components. @@ -957,7 +1011,7 @@ def _load_file( cache[comp_or_platform] = module - return module + return cast(ComponentProtocol, module) except ImportError as err: # This error happens if for example custom_components/switch @@ -981,7 +1035,7 @@ def _load_file( class ModuleWrapper: """Class to wrap a Python module and auto fill in hass argument.""" - def __init__(self, hass: HomeAssistant, module: ModuleType) -> None: + def __init__(self, hass: HomeAssistant, module: ComponentProtocol) -> None: """Initialize the module wrapper.""" self._hass = hass self._module = module @@ -1010,7 +1064,7 @@ class Components: integration = self._hass.data.get(DATA_INTEGRATIONS, {}).get(comp_name) if isinstance(integration, Integration): - component: ModuleType | None = integration.get_component() + component: ComponentProtocol | None = integration.get_component() else: # Fallback to importing old-school component = _load_file(self._hass, comp_name, _lookup_path(self._hass)) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index ce502116cf..f217aa297e 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -236,7 +236,7 @@ async def _async_setup_component( SLOW_SETUP_WARNING, ) - task = None + task: Awaitable[bool] | None = None result: Any | bool = True try: if hasattr(component, "async_setup"): diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a84d578cf5..b917ba2f3c 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -202,6 +202,14 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type="bool", ), + TypeHintMatch( + function_name="async_reset_platform", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type=None, + ), ], "__any_platform__": [ TypeHintMatch( From 8256d9b47224044e0ae67e42872b030e15b00b67 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 31 Mar 2023 20:30:04 +0200 Subject: [PATCH 0396/1165] Remove xbox_live integration (#90592) --- .coveragerc | 1 - CODEOWNERS | 1 - homeassistant/brands/microsoft.json | 3 +- .../components/xbox_live/__init__.py | 1 - .../components/xbox_live/manifest.json | 9 - homeassistant/components/xbox_live/sensor.py | 156 ------------------ .../components/xbox_live/strings.json | 8 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - 9 files changed, 1 insertion(+), 187 deletions(-) delete mode 100644 homeassistant/components/xbox_live/__init__.py delete mode 100644 homeassistant/components/xbox_live/manifest.json delete mode 100644 homeassistant/components/xbox_live/sensor.py delete mode 100644 homeassistant/components/xbox_live/strings.json diff --git a/.coveragerc b/.coveragerc index d313da55dd..87886b8412 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1438,7 +1438,6 @@ omit = homeassistant/components/xbox/media_player.py homeassistant/components/xbox/remote.py homeassistant/components/xbox/sensor.py - homeassistant/components/xbox_live/sensor.py homeassistant/components/xeoma/camera.py homeassistant/components/xiaomi/camera.py homeassistant/components/xiaomi_aqara/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 0e918caade..88df4edd6f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1367,7 +1367,6 @@ build.json @home-assistant/supervisor /tests/components/ws66i/ @ssaenger /homeassistant/components/xbox/ @hunterjm /tests/components/xbox/ @hunterjm -/homeassistant/components/xbox_live/ @MartinHjelmare /homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi /tests/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79 diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index d28932082a..9da24e76f1 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -10,7 +10,6 @@ "microsoft_face", "microsoft", "msteams", - "xbox", - "xbox_live" + "xbox" ] } diff --git a/homeassistant/components/xbox_live/__init__.py b/homeassistant/components/xbox_live/__init__.py deleted file mode 100644 index cc9e8ac351..0000000000 --- a/homeassistant/components/xbox_live/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The xbox_live component.""" diff --git a/homeassistant/components/xbox_live/manifest.json b/homeassistant/components/xbox_live/manifest.json deleted file mode 100644 index bf3e798da0..0000000000 --- a/homeassistant/components/xbox_live/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "xbox_live", - "name": "Xbox Live", - "codeowners": ["@MartinHjelmare"], - "documentation": "https://www.home-assistant.io/integrations/xbox_live", - "iot_class": "cloud_polling", - "loggers": ["xboxapi"], - "requirements": ["xboxapi==2.0.1"] -} diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py deleted file mode 100644 index 2ad3f75468..0000000000 --- a/homeassistant/components/xbox_live/sensor.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Sensor for Xbox Live account status.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import voluptuous as vol -from xboxapi import Client - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -CONF_XUID = "xuid" - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_XUID): vol.All(cv.ensure_list, [cv.string]), - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Xbox platform.""" - create_issue( - hass, - "xbox_live", - "pending_removal", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="pending_removal", - ) - _LOGGER.warning( - "The Xbox Live integration is deprecated " - "and will be removed in Home Assistant 2023.2" - ) - api = Client(api_key=config[CONF_API_KEY]) - entities = [] - - # request profile info to check api connection - response = api.api_get("profile") - if not response.ok: - _LOGGER.error( - ( - "Can't setup X API connection. Check your account or " - "api key on xapi.us. Code: %s Description: %s " - ), - response.status_code, - response.reason, - ) - return - - users = config[CONF_XUID] - - interval = timedelta(minutes=1 * len(users)) - interval = config.get(CONF_SCAN_INTERVAL, interval) - - for xuid in users: - if (gamercard := get_user_gamercard(api, xuid)) is None: - continue - entities.append(XboxSensor(api, xuid, gamercard, interval)) - - add_entities(entities, True) - - -def get_user_gamercard(api, xuid): - """Get profile info.""" - gamercard = api.gamer(gamertag="", xuid=xuid).get("gamercard") - _LOGGER.debug("User gamercard: %s", gamercard) - - if gamercard.get("success", True) and gamercard.get("code") is None: - return gamercard - _LOGGER.error( - "Can't get user profile %s. Error Code: %s Description: %s", - xuid, - gamercard.get("code", "unknown"), - gamercard.get("description", "unknown"), - ) - return None - - -class XboxSensor(SensorEntity): - """A class for the Xbox account.""" - - _attr_icon = "mdi:microsoft-xbox" - _attr_should_poll = False - - def __init__(self, api, xuid, gamercard, interval): - """Initialize the sensor.""" - self._state = None - self._presence = [] - self._xuid = xuid - self._api = api - self._gamertag = gamercard["gamertag"] - self._gamerscore = gamercard["gamerscore"] - self._interval = interval - self._picture = gamercard["gamerpicSmallSslImagePath"] - self._tier = gamercard["tier"] - - @property - def name(self): - """Return the name of the sensor.""" - return self._gamertag - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attributes = {"gamerscore": self._gamerscore, "tier": self._tier} - - for device in self._presence: - for title in device["titles"]: - attributes[f'{device["type"]} {title["placement"]}'] = title["name"] - - return attributes - - @property - def entity_picture(self): - """Avatar of the account.""" - return self._picture - - async def async_added_to_hass(self) -> None: - """Start custom polling.""" - - @callback - def async_update(event_time=None): - """Update the entity.""" - self.async_schedule_update_ha_state(True) - - async_track_time_interval(self.hass, async_update, self._interval) - - def update(self) -> None: - """Update state data from Xbox API.""" - presence = self._api.gamer(gamertag="", xuid=self._xuid).get("presence") - _LOGGER.debug("User presence: %s", presence) - self._state = presence["state"] - self._presence = presence.get("devices", []) diff --git a/homeassistant/components/xbox_live/strings.json b/homeassistant/components/xbox_live/strings.json deleted file mode 100644 index 0f73f851bd..0000000000 --- a/homeassistant/components/xbox_live/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "pending_removal": { - "title": "The Xbox Live integration is being removed", - "description": "The Xbox Live integration is pending removal from Home Assistant and will no longer be available as of Home Assistant 2023.2.\n\nThe integration is being removed, because it is only useful for the legacy device Xbox 360 and the upstream API now requires a paid subscription. Newer consoles are supported by the Xbox integration for free.\n\nRemove the Xbox Live YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 02273b8d97..9e5155f0cb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3293,12 +3293,6 @@ "config_flow": true, "iot_class": "cloud_polling", "name": "Xbox" - }, - "xbox_live": { - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling", - "name": "Xbox Live" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 2906cda9a4..9a6aaec298 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2643,9 +2643,6 @@ wolf_smartset==0.1.11 # homeassistant.components.xbox xbox-webapi==2.0.11 -# homeassistant.components.xbox_live -xboxapi==2.0.1 - # homeassistant.components.xiaomi_ble xiaomi-ble==0.16.4 From 09d54428c980f21f5e477241ef7be96a126bfe7b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Mar 2023 20:31:04 +0200 Subject: [PATCH 0397/1165] Bump reolink-aio to 0.5.9 (#90590) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 79fc15c571..b8de6cd839 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.8"] + "requirements": ["reolink-aio==0.5.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9a6aaec298..cf5ca371c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1966555f7..edb2c970bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 From 44eaf70625948b0e5fb5f77df7b953c6c2c3b7fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 08:33:44 -1000 Subject: [PATCH 0398/1165] Make sonos activity check a background task (#90553) Ensures the task is canceled at shutdown if the device is offline and the ping is still in progress --- homeassistant/components/sonos/speaker.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f97d134c9c..638ede722f 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -591,13 +591,20 @@ class SonosSpeaker: self.async_write_entity_states() self.hass.async_create_task(self.async_subscribe()) - async def async_check_activity(self, now: datetime.datetime) -> None: + @callback + def async_check_activity(self, now: datetime.datetime) -> None: """Validate availability of the speaker based on recent activity.""" if not self.available: return if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT: return + # Ensure the ping is canceled at shutdown + self.hass.async_create_background_task( + self._async_check_activity(), f"sonos {self.uid} {self.zone_name} ping" + ) + async def _async_check_activity(self) -> None: + """Validate availability of the speaker based on recent activity.""" try: await self.hass.async_add_executor_job(self.ping) except SonosUpdateError: From 8018be28eef93b3f3cb93092f27bef8af94e2896 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 14:34:42 -0400 Subject: [PATCH 0399/1165] TTS: allow resolving engine and test supported options (#90539) TTS: allow resolving engine --- homeassistant/components/tts/__init__.py | 38 ++++++++++++++++++++ homeassistant/components/tts/media_source.py | 12 +++---- tests/components/tts/test_init.py | 24 +++++++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index aa8864ad23..119a013ebf 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -136,6 +136,44 @@ class TTSCache(TypedDict): voice: bytes +@callback +def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: + """Resolve engine. + + Returns None if no engines found or invalid engine passed in. + """ + manager: SpeechManager = hass.data[DOMAIN] + + if engine is not None: + if engine not in manager.providers: + return None + return engine + + if not manager.providers: + return None + + if "cloud" in manager.providers: + return "cloud" + + return next(iter(manager.providers)) + + +async def async_support_options( + hass: HomeAssistant, + engine: str, + language: str | None = None, + options: dict | None = None, +) -> bool: + """Return if an engine supports options.""" + manager: SpeechManager = hass.data[DOMAIN] + try: + manager.process_options(engine, language, options) + except HomeAssistantError: + return False + + return True + + async def async_get_media_source_audio( hass: HomeAssistant, media_source_id: str, diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index c197632c11..f52292e809 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -40,16 +40,12 @@ def generate_media_source_id( cache: bool | None = None, ) -> str: """Generate a media source ID for text-to-speech.""" + from . import async_resolve_engine # pylint: disable=import-outside-toplevel + manager: SpeechManager = hass.data[DOMAIN] - if engine is not None: - pass - elif not manager.providers: - raise HomeAssistantError("No TTS providers available") - elif "cloud" in manager.providers: - engine = "cloud" - else: - engine = next(iter(manager.providers)) + if (engine := async_resolve_engine(hass, engine)) is None: + raise HomeAssistantError("Invalid TTS provider selected") manager.process_options(engine, language, options) params = { diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 251ed9b30c..694c9ff676 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1,6 +1,7 @@ """The tests for the TTS component.""" from http import HTTPStatus from typing import Any +from unittest.mock import patch import pytest import voluptuous as vol @@ -972,3 +973,26 @@ async def test_generate_media_source_id_invalid_options( """Test generating a media source ID.""" with pytest.raises(HomeAssistantError): tts.generate_media_source_id(hass, "msg", engine, language, options, None) + + +def test_resolve_engine(hass: HomeAssistant, setup_tts) -> None: + """Test resolving engine.""" + assert tts.async_resolve_engine(hass, None) == "test" + assert tts.async_resolve_engine(hass, "test") == "test" + assert tts.async_resolve_engine(hass, "non-existing") is None + + with patch.dict(hass.data[tts.DOMAIN].providers, {}, clear=True): + assert tts.async_resolve_engine(hass, "test") is None + + with patch.dict(hass.data[tts.DOMAIN].providers, {"cloud": object()}): + assert tts.async_resolve_engine(hass, None) == "cloud" + + +async def test_support_options(hass: HomeAssistant, setup_tts) -> None: + """Test supporting options.""" + assert await tts.async_support_options(hass, "test", "en") is True + assert await tts.async_support_options(hass, "test", "nl") is False + assert ( + await tts.async_support_options(hass, "test", "en", {"invalid_option": "yo"}) + is False + ) From ad26317b75df41eefba857691d1b4a0ae135ce79 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 14:36:39 -0400 Subject: [PATCH 0400/1165] Conversation: allow getting agent info (#90540) * Conversation: allow getting agent info * Add unset agenet back --- .../components/conversation/__init__.py | 43 +++++++++++++++++-- .../conversation/snapshots/test_init.ambr | 34 +++++++++++++++ tests/components/conversation/test_init.py | 28 ++++++++---- 3 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 tests/components/conversation/snapshots/test_init.ambr diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index e2e00a2652..5009530dc3 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import logging import re -from typing import Any +from typing import Any, TypedDict import voluptuous as vol @@ -20,6 +20,15 @@ from homeassistant.loader import bind_hass from .agent import AbstractConversationAgent, ConversationInput, ConversationResult from .default_agent import DefaultAgent +__all__ = [ + "DOMAIN", + "async_converse", + "async_get_agent_info", + "async_set_agent", + "async_unset_agent", + "async_setup", +] + _LOGGER = logging.getLogger(__name__) ATTR_TEXT = "text" @@ -270,6 +279,31 @@ class ConversationProcessView(http.HomeAssistantView): return self.json(result.as_dict()) +class AgentInfo(TypedDict): + """Dictionary holding agent info.""" + + id: str + name: str + + +@core.callback +def async_get_agent_info( + hass: core.HomeAssistant, + agent_id: str | None = None, +) -> AgentInfo | None: + """Get information on the agent or None if not found.""" + manager = _get_agent_manager(hass) + + if agent_id is None: + agent_id = manager.default_agent + + for agent_info in manager.async_get_agent_info(): + if agent_info["id"] == agent_id: + return agent_info + + return None + + async def async_converse( hass: core.HomeAssistant, text: str, @@ -332,12 +366,15 @@ class AgentManager: return self._builtin_agent + if agent_id not in self._agents: + raise ValueError(f"Agent {agent_id} not found") + return self._agents[agent_id] @core.callback - def async_get_agent_info(self) -> list[dict[str, Any]]: + def async_get_agent_info(self) -> list[AgentInfo]: """List all agents.""" - agents = [ + agents: list[AgentInfo] = [ { "id": AgentManager.HOME_ASSISTANT_AGENT, "name": "Home Assistant", diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr new file mode 100644 index 0000000000..1547b5b5e8 --- /dev/null +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_get_agent_info + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + }) +# --- +# name: test_get_agent_info.1 + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + }) +# --- +# name: test_get_agent_info.2 + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + }) +# --- +# name: test_get_agent_list + dict({ + 'agents': list([ + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + }), + ]), + 'default_agent': 'mock-entry', + }) +# --- diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 55a345bd60..eb38d875bf 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation @@ -929,7 +930,11 @@ async def test_agent_id_validator_invalid_agent(hass: HomeAssistant) -> None: async def test_get_agent_list( - hass: HomeAssistant, init_components, mock_agent, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + init_components, + mock_agent, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test getting agent info.""" client = await hass_ws_client(hass) @@ -940,10 +945,17 @@ async def test_get_agent_list( assert msg["id"] == 5 assert msg["type"] == "result" assert msg["success"] - assert msg["result"] == { - "agents": [ - {"id": "homeassistant", "name": "Home Assistant"}, - {"id": "mock-entry", "name": "Mock Title"}, - ], - "default_agent": "mock-entry", - } + assert msg["result"] == snapshot + + +async def test_get_agent_info( + hass: HomeAssistant, init_components, mock_agent, snapshot: SnapshotAssertion +) -> None: + """Test get agent info.""" + agent_info = conversation.async_get_agent_info(hass) + # Test it's the default + assert agent_info["id"] == mock_agent.agent_id + assert agent_info == snapshot + assert conversation.async_get_agent_info(hass, "homeassistant") == snapshot + assert conversation.async_get_agent_info(hass, mock_agent.agent_id) == snapshot + assert conversation.async_get_agent_info(hass, "not exist") is None From 84eb9c5f97fffdbb5312fe406e48ea19571e35e0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:53:42 -0400 Subject: [PATCH 0401/1165] Fix ZHA definition error on received command (#90602) * Fix use of deprecated command schema access * Add a unit test --- .../components/zha/core/channels/base.py | 10 +++++++--- tests/components/zha/test_base.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 tests/components/zha/test_base.py diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index ae5980cd63..6d4899be37 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -58,15 +58,19 @@ class AttrReportConfig(TypedDict, total=True): def parse_and_log_command(channel, tsn, command_id, args): """Parse and log a zigbee cluster command.""" - cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] + try: + name = channel.cluster.server_commands[command_id].name + except KeyError: + name = f"0x{command_id:02X}" + channel.debug( "received '%s' command with %s args on cluster_id '%s' tsn '%s'", - cmd, + name, args, channel.cluster.cluster_id, tsn, ) - return cmd + return name def decorate_command(channel, command): diff --git a/tests/components/zha/test_base.py b/tests/components/zha/test_base.py new file mode 100644 index 0000000000..fbb25f1cbd --- /dev/null +++ b/tests/components/zha/test_base.py @@ -0,0 +1,19 @@ +"""Test ZHA base channel module.""" + +from homeassistant.components.zha.core.channels.base import parse_and_log_command + +from tests.components.zha.test_channels import ( # noqa: F401 + channel_pool, + poll_control_ch, + zigpy_coordinator_device, +) + + +def test_parse_and_log_command(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses a known command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0x01, []) == "fast_poll_stop" + + +def test_parse_and_log_command_unknown(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses an unknown command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0xAB, []) == "0xAB" From 01a05340c69cb3f5a6159e520b19bef65a13c5e7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 15:04:22 -0400 Subject: [PATCH 0402/1165] Voice Assistant: improve error handling (#90541) Co-authored-by: Michael Hansen --- homeassistant/components/stt/__init__.py | 16 +- .../components/voice_assistant/pipeline.py | 169 ++++++++++++------ .../voice_assistant/websocket_api.py | 53 +++--- .../snapshots/test_websocket.ambr | 25 ++- .../voice_assistant/test_websocket.py | 29 +-- 5 files changed, 178 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 6319940219..b858cc743a 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -36,12 +36,20 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_get_provider(hass: HomeAssistant, domain: str | None = None) -> Provider: +def async_get_provider( + hass: HomeAssistant, domain: str | None = None +) -> Provider | None: """Return provider.""" - if domain is None: - domain = next(iter(hass.data[DOMAIN])) + if domain: + return hass.data[DOMAIN].get(domain) - return hass.data[DOMAIN][domain] + if not hass.data[DOMAIN]: + return None + + if "cloud" in hass.data[DOMAIN]: + return hass.data[DOMAIN]["cloud"] + + return next(iter(hass.data[DOMAIN].values())) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 806a603f5e..ef13d54e6a 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -8,7 +8,7 @@ import logging from typing import Any from homeassistant.backports.enum import StrEnum -from homeassistant.components import conversation, media_source, stt +from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.tts.media_source import ( generate_media_source_id as tts_generate_media_source_id, ) @@ -17,8 +17,6 @@ from homeassistant.util.dt import utcnow from .const import DOMAIN -DEFAULT_TIMEOUT = 30 # seconds - _LOGGER = logging.getLogger(__name__) @@ -151,6 +149,9 @@ class PipelineRun: event_callback: Callable[[PipelineEvent], None] language: str = None # type: ignore[assignment] runner_data: Any | None = None + stt_provider: stt.Provider | None = None + intent_agent: str | None = None + tts_engine: str | None = None def __post_init__(self): """Set language for pipeline.""" @@ -181,13 +182,39 @@ class PipelineRun: ) ) + async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: + """Prepare speech to text.""" + stt_provider = stt.async_get_provider(self.hass, self.pipeline.stt_engine) + + if stt_provider is None: + engine = self.pipeline.stt_engine or "default" + raise SpeechToTextError( + code="stt-provider-missing", + message=f"No speech to text provider for: {engine}", + ) + + if not stt_provider.check_metadata(metadata): + raise SpeechToTextError( + code="stt-provider-unsupported-metadata", + message=( + f"Provider {engine} does not support input speech " + "to text metadata" + ), + ) + + self.stt_provider = stt_provider + async def speech_to_text( self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes], ) -> str: """Run speech to text portion of pipeline. Returns the spoken text.""" - engine = self.pipeline.stt_engine or "default" + if self.stt_provider is None: + raise RuntimeError("Speech to text was not prepared") + + engine = self.stt_provider.name + self.event_callback( PipelineEvent( PipelineEventType.STT_START, @@ -198,28 +225,11 @@ class PipelineRun: ) ) - try: - # Load provider - stt_provider: stt.Provider = stt.async_get_provider( - self.hass, self.pipeline.stt_engine - ) - assert stt_provider is not None - except Exception as src_error: - _LOGGER.exception("No speech to text provider for %s", engine) - raise SpeechToTextError( - code="stt-provider-missing", - message=f"No speech to text provider for: {engine}", - ) from src_error - - if not stt_provider.check_metadata(metadata): - raise SpeechToTextError( - code="stt-provider-unsupported-metadata", - message=f"Provider {engine} does not support input speech to text metadata", - ) - try: # Transcribe audio stream - result = await stt_provider.async_process_audio_stream(metadata, stream) + result = await self.stt_provider.async_process_audio_stream( + metadata, stream + ) except Exception as src_error: _LOGGER.exception("Unexpected error during speech to text") raise SpeechToTextError( @@ -253,15 +263,33 @@ class PipelineRun: return result.text + async def prepare_recognize_intent(self) -> None: + """Prepare recognizing an intent.""" + agent_info = conversation.async_get_agent_info( + self.hass, self.pipeline.conversation_engine + ) + + if agent_info is None: + engine = self.pipeline.conversation_engine or "default" + raise IntentRecognitionError( + code="intent-not-supported", + message=f"Intent recognition engine {engine} is not found", + ) + + self.intent_agent = agent_info["id"] + async def recognize_intent( self, intent_input: str, conversation_id: str | None ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" + if self.intent_agent is None: + raise RuntimeError("Recognize intent was not prepared") + self.event_callback( PipelineEvent( PipelineEventType.INTENT_START, { - "engine": self.pipeline.conversation_engine or "default", + "engine": self.intent_agent, "intent_input": intent_input, }, ) @@ -274,7 +302,7 @@ class PipelineRun: conversation_id=conversation_id, context=self.context, language=self.language, - agent_id=self.pipeline.conversation_engine, + agent_id=self.intent_agent, ) except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") @@ -296,13 +324,38 @@ class PipelineRun: return speech + async def prepare_text_to_speech(self) -> None: + """Prepare text to speech.""" + engine = tts.async_resolve_engine(self.hass, self.pipeline.tts_engine) + + if engine is None: + engine = self.pipeline.tts_engine or "default" + raise TextToSpeechError( + code="tts-not-supported", + message=f"Text to speech engine '{engine}' not found", + ) + + if not await tts.async_support_options(self.hass, engine, self.language): + raise TextToSpeechError( + code="tts-not-supported", + message=( + f"Text to speech engine {engine} " + f"does not support language {self.language}" + ), + ) + + self.tts_engine = engine + async def text_to_speech(self, tts_input: str) -> str: """Run text to speech portion of pipeline. Returns URL of TTS audio.""" + if self.tts_engine is None: + raise RuntimeError("Text to speech was not prepared") + self.event_callback( PipelineEvent( PipelineEventType.TTS_START, { - "engine": self.pipeline.tts_engine or "default", + "engine": self.tts_engine, "tts_input": tts_input, }, ) @@ -315,7 +368,8 @@ class PipelineRun: tts_generate_media_source_id( self.hass, tts_input, - engine=self.pipeline.tts_engine, + engine=self.tts_engine, + language=self.language, ), ) except Exception as src_error: @@ -341,6 +395,8 @@ class PipelineRun: class PipelineInput: """Input to a pipeline run.""" + run: PipelineRun + stt_metadata: stt.SpeechMetadata | None = None """Metadata of stt input audio. Required when start_stage = stt.""" @@ -355,21 +411,10 @@ class PipelineInput: conversation_id: str | None = None - async def execute( - self, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT - ): - """Run pipeline with optional timeout.""" - await asyncio.wait_for( - self._execute(run), - timeout=timeout, - ) - - async def _execute(self, run: PipelineRun): - self._validate(run.start_stage) - - # stt -> intent -> tts - run.start() - current_stage = run.start_stage + async def execute(self): + """Run pipeline.""" + self.run.start() + current_stage = self.run.start_stage try: # Speech to text @@ -377,29 +422,29 @@ class PipelineInput: if current_stage == PipelineStage.STT: assert self.stt_metadata is not None assert self.stt_stream is not None - intent_input = await run.speech_to_text( + intent_input = await self.run.speech_to_text( self.stt_metadata, self.stt_stream, ) current_stage = PipelineStage.INTENT - if run.end_stage != PipelineStage.STT: + if self.run.end_stage != PipelineStage.STT: tts_input = self.tts_input if current_stage == PipelineStage.INTENT: assert intent_input is not None - tts_input = await run.recognize_intent( + tts_input = await self.run.recognize_intent( intent_input, self.conversation_id ) current_stage = PipelineStage.TTS - if run.end_stage != PipelineStage.INTENT: + if self.run.end_stage != PipelineStage.INTENT: if current_stage == PipelineStage.TTS: assert tts_input is not None - await run.text_to_speech(tts_input) + await self.run.text_to_speech(tts_input) except PipelineError as err: - run.event_callback( + self.run.event_callback( PipelineEvent( PipelineEventType.ERROR, {"code": err.code, "message": err.message}, @@ -407,11 +452,11 @@ class PipelineInput: ) return - run.end() + self.run.end() - def _validate(self, stage: PipelineStage): + async def validate(self): """Validate pipeline input against start stage.""" - if stage == PipelineStage.STT: + if self.run.start_stage == PipelineStage.STT: if self.stt_metadata is None: raise PipelineRunValidationError( "stt_metadata is required for speech to text" @@ -421,13 +466,29 @@ class PipelineInput: raise PipelineRunValidationError( "stt_stream is required for speech to text" ) - elif stage == PipelineStage.INTENT: + elif self.run.start_stage == PipelineStage.INTENT: if self.intent_input is None: raise PipelineRunValidationError( "intent_input is required for intent recognition" ) - elif stage == PipelineStage.TTS: + elif self.run.start_stage == PipelineStage.TTS: if self.tts_input is None: raise PipelineRunValidationError( "tts_input is required for text to speech" ) + + start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage) + + prepare_tasks = [] + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT): + prepare_tasks.append(self.run.prepare_speech_to_text(self.stt_metadata)) + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT): + prepare_tasks.append(self.run.prepare_recognize_intent()) + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS): + prepare_tasks.append(self.run.prepare_text_to_speech()) + + if prepare_tasks: + await asyncio.gather(*prepare_tasks) diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index 28cafb7a35..aa295ad5c6 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -5,13 +5,13 @@ from collections.abc import Callable import logging from typing import Any +import async_timeout import voluptuous as vol from homeassistant.components import stt, websocket_api from homeassistant.core import HomeAssistant, callback from .pipeline import ( - DEFAULT_TIMEOUT, PipelineError, PipelineEvent, PipelineEventType, @@ -21,6 +21,8 @@ from .pipeline import ( async_get_pipeline, ) +DEFAULT_TIMEOUT = 30 + _LOGGER = logging.getLogger(__name__) _VAD_ENERGY_THRESHOLD = 1000 @@ -155,37 +157,40 @@ async def websocket_run( # Input to text to speech system input_args["tts_input"] = msg["input"]["text"] - run_task = hass.async_create_task( - PipelineInput(**input_args).execute( - PipelineRun( - hass, - context=connection.context(msg), - pipeline=pipeline, - start_stage=start_stage, - end_stage=end_stage, - event_callback=lambda event: connection.send_event( - msg["id"], event.as_dict() - ), - runner_data={ - "stt_binary_handler_id": handler_id, - }, - ), - timeout=timeout, - ) + input_args["run"] = PipelineRun( + hass, + context=connection.context(msg), + pipeline=pipeline, + start_stage=start_stage, + end_stage=end_stage, + event_callback=lambda event: connection.send_event(msg["id"], event.as_dict()), + runner_data={ + "stt_binary_handler_id": handler_id, + "timeout": timeout, + }, ) - # Cancel pipeline if user unsubscribes - connection.subscriptions[msg["id"]] = run_task.cancel + pipeline_input = PipelineInput(**input_args) + + try: + await pipeline_input.validate() + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + return # Confirm subscription connection.send_result(msg["id"]) + run_task = hass.async_create_task(pipeline_input.execute()) + + # Cancel pipeline if user unsubscribes + connection.subscriptions[msg["id"]] = run_task.cancel + try: # Task contains a timeout - await run_task - except PipelineError as error: - # Report more specific error when possible - connection.send_error(msg["id"], error.code, error.message) + async with async_timeout.timeout(timeout): + await run_task except asyncio.TimeoutError: connection.send_event( msg["id"], diff --git a/tests/components/voice_assistant/snapshots/test_websocket.ambr b/tests/components/voice_assistant/snapshots/test_websocket.ambr index c18af44b21..a5812d170f 100644 --- a/tests/components/voice_assistant/snapshots/test_websocket.ambr +++ b/tests/components/voice_assistant/snapshots/test_websocket.ambr @@ -5,12 +5,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- # name: test_audio_pipeline.1 dict({ - 'engine': 'default', + 'engine': 'test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -30,7 +31,7 @@ # --- # name: test_audio_pipeline.3 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'test transcript', }) # --- @@ -58,7 +59,7 @@ # --- # name: test_audio_pipeline.5 dict({ - 'engine': 'default', + 'engine': 'test', 'tts_input': "Sorry, I couldn't understand that", }) # --- @@ -66,7 +67,7 @@ dict({ 'tts_output': dict({ 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_-_test.mp3', }), }) # --- @@ -76,12 +77,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_intent_failed.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', }) # --- @@ -91,12 +93,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 0.1, }), }) # --- # name: test_intent_timeout.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', }) # --- @@ -112,6 +115,7 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- @@ -134,12 +138,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- # name: test_stt_stream_failed.1 dict({ - 'engine': 'default', + 'engine': 'test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -156,12 +161,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_text_only_pipeline.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', }) # --- @@ -199,12 +205,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_tts_failed.1 dict({ - 'engine': 'default', + 'engine': 'test', 'tts_input': 'Lights are on.', }) # --- diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index 149d896dcf..ce87655032 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -93,7 +93,7 @@ class MockTTSProvider(tts.Provider): @property def supported_languages(self) -> list[str]: """Return list of supported languages.""" - return ["en"] + return ["en-US"] @property def supported_options(self) -> list[str]: @@ -264,7 +264,7 @@ async def test_intent_timeout( "start_stage": "intent", "end_stage": "intent", "input": {"text": "Are the lights on?"}, - "timeout": 0.00001, + "timeout": 0.1, } ) @@ -301,7 +301,7 @@ async def test_text_pipeline_timeout( await asyncio.sleep(3600) with patch( - "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", + "homeassistant.components.voice_assistant.pipeline.PipelineInput.execute", new=sleepy_run, ): await client.send_json( @@ -381,7 +381,7 @@ async def test_audio_pipeline_timeout( await asyncio.sleep(3600) with patch( - "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", + "homeassistant.components.voice_assistant.pipeline.PipelineInput.execute", new=sleepy_run, ): await client.send_json( @@ -427,25 +427,8 @@ async def test_stt_provider_missing( # result msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == snapshot - - # stt - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == snapshot - - # End of audio stream (handler id + empty payload) - await client.send_bytes(b"1") - - # stt error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "stt-provider-missing" + assert not msg["success"] + assert msg["error"]["code"] == "stt-provider-missing" async def test_stt_stream_failed( From 3f398818c56c982e3e1ec060e033c10b91c5d79c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:37:00 -0400 Subject: [PATCH 0403/1165] Perform an energy scan when downloading ZHA diagnostics (#90605) --- homeassistant/components/zha/diagnostics.py | 9 +++++++++ tests/components/zha/test_diagnostics.py | 20 ++++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 2e0653b47e..966f35fe98 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -7,6 +7,7 @@ from typing import Any from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.profiles import PROFILES +from zigpy.types import Channels from zigpy.zcl import Cluster from homeassistant.components.diagnostics.util import async_redact_data @@ -67,11 +68,19 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + energy_scan = await gateway.application_controller.energy_scan( + channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 + ) + return async_redact_data( { "config": config, "config_entry": config_entry.as_dict(), "application_state": shallow_asdict(gateway.application_controller.state), + "energy_scan": { + channel: 100 * energy / 255 for channel, energy in energy_scan.items() + }, "versions": { "bellows": version("bellows"), "zigpy": version("zigpy"), diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 61f855af9a..5ec555d88d 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,6 +6,7 @@ import zigpy.profiles.zha as zha import zigpy.zcl.clusters.security as security from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.zha.core.const import DATA_ZHA, DATA_ZHA_GATEWAY from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT from homeassistant.const import Platform @@ -62,14 +63,25 @@ async def test_diagnostics_for_config_entry( ) -> None: """Test diagnostics for config entry.""" await zha_device_joined(zigpy_device) - diagnostics_data = await get_diagnostics_for_config_entry( - hass, hass_client, config_entry - ) - assert diagnostics_data + + gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + scan = {c: c for c in range(11, 26 + 1)} + + with patch.object(gateway.application_controller, "energy_scan", return_value=scan): + diagnostics_data = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + for key in CONFIG_ENTRY_DIAGNOSTICS_KEYS: assert key in diagnostics_data assert diagnostics_data[key] is not None + # Energy scan results are presented as a percentage. JSON object keys also must be + # strings, not integers. + assert diagnostics_data["energy_scan"] == { + str(k): 100 * v / 255 for k, v in scan.items() + } + async def test_diagnostics_for_device( hass: HomeAssistant, From 6db96847d557896ec34ba47eebcc0524daa8528e Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:39:08 -0400 Subject: [PATCH 0404/1165] Bump zwave-js-server-python to 0.47.3 (#90606) * Bump zwave-js-server-python to 0.47.2 * Bump zwave-js-server-python to 0.47.3 --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 5fb7726577..d41ee0272a 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.3"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index cf5ca371c7..935d7bdb69 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2725,7 +2725,7 @@ zigpy==0.54.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edb2c970bc..27d71f3d56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1959,7 +1959,7 @@ zigpy-znp==0.10.0 zigpy==0.54.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 From ab66664f20f9a9d547b3b3b7e35608e245f7b54c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:34:20 +0200 Subject: [PATCH 0405/1165] Allow removal of sensor settings in scrape (#90412) * Allow removal of sensor settings in scrape * Adjust * Adjust * Add comment * Simplify * Simplify * Adjust * Don't allow empty string * Only allow None * Use default as None * Use sentinel "none" * Not needed * Adjust unit of measurement * Add translation keys for "none" * Use translations * Sort * Add enum and timestamp * Use translation references * Remove default and set suggested_values * Disallow enum device class * Adjust tests * Adjust _strip_sentinel --- .../components/scrape/config_flow.py | 39 +++- homeassistant/components/scrape/strings.json | 67 ++++++ tests/components/scrape/conftest.py | 13 +- tests/components/scrape/test_config_flow.py | 193 +++++++++++++++++- 4 files changed, 294 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 1e3635a010..3ca13e56b2 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -95,6 +95,8 @@ RESOURCE_SETUP = { vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } +NONE_SENTINEL = "none" + SENSOR_SETUP = { vol.Required(CONF_SELECT): TextSelector(), vol.Optional(CONF_INDEX, default=0): NumberSelector( @@ -102,28 +104,45 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + vol.Required(CONF_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorDeviceClass], + options=[NONE_SENTINEL] + + sorted( + [ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ] + ), mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class", ) ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( + vol.Required(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], + options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), mode=SelectSelectorMode.DROPDOWN, + translation_key="state_class", ) ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], + options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), custom_value=True, mode=SelectSelectorMode.DROPDOWN, + translation_key="unit_of_measurement", ) ), } +def _strip_sentinel(options: dict[str, Any]) -> None: + """Convert sentinel to None.""" + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if options[key] == NONE_SENTINEL: + options.pop(key) + + async def validate_rest_setup( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -150,6 +169,7 @@ async def validate_sensor_setup( # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) + _strip_sentinel(user_input) sensors.append(user_input) return {} @@ -181,7 +201,11 @@ async def get_edit_sensor_suggested_values( ) -> dict[str, Any]: """Return suggested values for sensor editing.""" idx: int = handler.flow_state["_idx"] - return cast(dict[str, Any], handler.options[SENSOR_DOMAIN][idx]) + suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx]) + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if not suggested_values.get(key): + suggested_values[key] = NONE_SENTINEL + return suggested_values async def validate_sensor_edit( @@ -194,6 +218,7 @@ async def validate_sensor_edit( # In this case, we want to add a sub-item so we update the options directly. idx: int = handler.flow_state["_idx"] handler.options[SENSOR_DOMAIN][idx].update(user_input) + _strip_sentinel(handler.options[SENSOR_DOMAIN][idx]) return {} diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 052ef22848..857d53eb52 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -125,5 +125,72 @@ } } } + }, + "selector": { + "device_class": { + "options": { + "none": "No device class", + "date": "[%key:component::sensor::entity_component::date::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "state_class": { + "options": { + "none": "No state class", + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + }, + "unit_of_measurement": { + "options": { + "none": "No unit of measurement" + } + } } } diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py index 5ad4f39844..026daeea38 100644 --- a/tests/components/scrape/conftest.py +++ b/tests/components/scrape/conftest.py @@ -1,8 +1,9 @@ """Fixtures for the Scrape integration.""" from __future__ import annotations +from collections.abc import Generator from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid import pytest @@ -32,6 +33,16 @@ from . import MockRestData from tests.common import MockConfigEntry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Automatically path uuid generator.""" + with patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="get_config") async def get_config_to_integration_load() -> dict[str, Any]: """Return default minimal configuration. diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index e508937fed..9c6c5e0b4d 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -1,13 +1,14 @@ """Test the Scrape config flow.""" from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid from homeassistant import config_entries from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.scrape import DOMAIN +from homeassistant.components.scrape.config_flow import NONE_SENTINEL from homeassistant.components.scrape.const import ( CONF_ENCODING, CONF_INDEX, @@ -15,14 +16,18 @@ from homeassistant.components.scrape.const import ( DEFAULT_ENCODING, DEFAULT_VERIFY_SSL, ) +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_METHOD, CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, CONF_TIMEOUT, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant @@ -34,7 +39,9 @@ from . import MockRestData from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_form( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -46,10 +53,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ) as mock_data, patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_data: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -66,6 +70,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -92,7 +99,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_flow_fails( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test config flow error.""" result = await hass.config_entries.flow.async_init( @@ -137,9 +146,6 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ), patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -157,6 +163,9 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -278,6 +287,9 @@ async def test_options_add_remove_sensor_flow( CONF_NAME: "Template", CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -405,6 +417,9 @@ async def test_options_edit_sensor_flow( user_input={ CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -434,3 +449,161 @@ async def test_options_edit_sensor_flow( # Check the state of the entity has changed as expected state = hass.states.get("sensor.current_version") assert state.state == "Trying to get" + + +async def test_sensor_options_add_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } + + +async def test_sensor_options_remove_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } From de9e7e47feb0baf23ca20b579c0a729a7170366f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 08:33:44 -1000 Subject: [PATCH 0406/1165] Make sonos activity check a background task (#90553) Ensures the task is canceled at shutdown if the device is offline and the ping is still in progress --- homeassistant/components/sonos/speaker.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f97d134c9c..638ede722f 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -591,13 +591,20 @@ class SonosSpeaker: self.async_write_entity_states() self.hass.async_create_task(self.async_subscribe()) - async def async_check_activity(self, now: datetime.datetime) -> None: + @callback + def async_check_activity(self, now: datetime.datetime) -> None: """Validate availability of the speaker based on recent activity.""" if not self.available: return if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT: return + # Ensure the ping is canceled at shutdown + self.hass.async_create_background_task( + self._async_check_activity(), f"sonos {self.uid} {self.zone_name} ping" + ) + async def _async_check_activity(self) -> None: + """Validate availability of the speaker based on recent activity.""" try: await self.hass.async_add_executor_job(self.ping) except SonosUpdateError: From 89dc6db5a76fbe27c9acfcf4187e0305e421f752 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 14:55:48 +0200 Subject: [PATCH 0407/1165] Add arming/disarming state to Verisure (#90577) --- homeassistant/components/verisure/alarm_control_panel.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 0cfd6ebb81..9615404a9a 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -9,6 +9,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -83,18 +84,24 @@ class VerisureAlarm( async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" + self._attr_state = STATE_ALARM_DISARMING + self.async_write_ha_state() await self._async_set_arm_state( "DISARMED", self.coordinator.verisure.disarm(code) ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_HOME", self.coordinator.verisure.arm_home(code) ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_AWAY", self.coordinator.verisure.arm_away(code) ) From 88a407361cd798a213b52f8f309ed43f096ab7b9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:16 +0200 Subject: [PATCH 0408/1165] Raise on invalid (dis)arm code in manual alarm (#90579) --- .../components/manual/alarm_control_panel.py | 51 +++++++------------ .../manual/test_alarm_control_panel.py | 23 ++++++--- 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index f0436ba1d6..da77aea6c4 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time @@ -285,56 +286,34 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -383,18 +362,22 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( parse_result=False, from_state=self._state, to_state=state ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 21cbc95d4e..f1a4b2da2e 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -26,6 +26,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -224,12 +225,16 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE + "2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + { + ATTR_ENTITY_ID: "alarm_control_panel.test", + ATTR_CODE: f"{CODE}2", + }, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -1082,7 +1087,8 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1125,7 +1131,8 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From 499962f4eeccd86867de75d9e5640b5e3aa1daf6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:50:49 +0200 Subject: [PATCH 0409/1165] Tweak yalexs_ble translations (#90582) --- homeassistant/components/yalexs_ble/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index 0f1f138fd6..c2d1a2155c 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -22,7 +22,7 @@ } }, "error": { - "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and again.", + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", From 2d482f1f5741da39cb7e71c0f22f2dee22b1b48e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:02 +0200 Subject: [PATCH 0410/1165] Raise on invalid (dis)arm code in manual mqtt alarm (#90584) --- .../manual_mqtt/alarm_control_panel.py | 51 +++++++------------ .../manual_mqtt/test_alarm_control_panel.py | 20 +++++--- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index d6b4a58c41..fd6adb009a 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -345,56 +346,34 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_schedule_update_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -436,18 +415,22 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( from_state=self._state, to_state=state, parse_result=False ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 8aaccad105..549fa99517 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -24,6 +24,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -280,12 +281,13 @@ async def test_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -881,7 +883,8 @@ async def test_disarm_during_trigger_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1307,7 +1310,8 @@ async def test_disarm_with_template_code( state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From a20771f57155e2a74839c3f45ecf0df26d2cfa8e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Mar 2023 20:31:04 +0200 Subject: [PATCH 0411/1165] Bump reolink-aio to 0.5.9 (#90590) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 79fc15c571..b8de6cd839 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.8"] + "requirements": ["reolink-aio==0.5.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8706e4e5f9..1a6737c99d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b1ce6ec33..9d44d8dabd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 From c63f8e714ee33c8994393d364de785b044aacf04 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Mar 2023 20:15:49 +0200 Subject: [PATCH 0412/1165] Update frontend to 20230331.0 (#90594) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6a2a904833..114760923e 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==20230330.0"] + "requirements": ["home-assistant-frontend==20230331.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 342942f0dd..cde6be3c20 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 1a6737c99d..9843965125 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d44d8dabd..a249c7c7db 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From f56ccf90d91b79113bf29f897d1b02e36ccc43b3 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:53:42 -0400 Subject: [PATCH 0413/1165] Fix ZHA definition error on received command (#90602) * Fix use of deprecated command schema access * Add a unit test --- .../components/zha/core/channels/base.py | 10 +++++++--- tests/components/zha/test_base.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 tests/components/zha/test_base.py diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index ae5980cd63..6d4899be37 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -58,15 +58,19 @@ class AttrReportConfig(TypedDict, total=True): def parse_and_log_command(channel, tsn, command_id, args): """Parse and log a zigbee cluster command.""" - cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] + try: + name = channel.cluster.server_commands[command_id].name + except KeyError: + name = f"0x{command_id:02X}" + channel.debug( "received '%s' command with %s args on cluster_id '%s' tsn '%s'", - cmd, + name, args, channel.cluster.cluster_id, tsn, ) - return cmd + return name def decorate_command(channel, command): diff --git a/tests/components/zha/test_base.py b/tests/components/zha/test_base.py new file mode 100644 index 0000000000..fbb25f1cbd --- /dev/null +++ b/tests/components/zha/test_base.py @@ -0,0 +1,19 @@ +"""Test ZHA base channel module.""" + +from homeassistant.components.zha.core.channels.base import parse_and_log_command + +from tests.components.zha.test_channels import ( # noqa: F401 + channel_pool, + poll_control_ch, + zigpy_coordinator_device, +) + + +def test_parse_and_log_command(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses a known command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0x01, []) == "fast_poll_stop" + + +def test_parse_and_log_command_unknown(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses an unknown command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0xAB, []) == "0xAB" From 590db0fa74c931728db2ed0074ddae36a8c6dec6 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:37:00 -0400 Subject: [PATCH 0414/1165] Perform an energy scan when downloading ZHA diagnostics (#90605) --- homeassistant/components/zha/diagnostics.py | 9 +++++++++ tests/components/zha/test_diagnostics.py | 20 ++++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 2e0653b47e..966f35fe98 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -7,6 +7,7 @@ from typing import Any from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.profiles import PROFILES +from zigpy.types import Channels from zigpy.zcl import Cluster from homeassistant.components.diagnostics.util import async_redact_data @@ -67,11 +68,19 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + energy_scan = await gateway.application_controller.energy_scan( + channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 + ) + return async_redact_data( { "config": config, "config_entry": config_entry.as_dict(), "application_state": shallow_asdict(gateway.application_controller.state), + "energy_scan": { + channel: 100 * energy / 255 for channel, energy in energy_scan.items() + }, "versions": { "bellows": version("bellows"), "zigpy": version("zigpy"), diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 61f855af9a..5ec555d88d 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,6 +6,7 @@ import zigpy.profiles.zha as zha import zigpy.zcl.clusters.security as security from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.zha.core.const import DATA_ZHA, DATA_ZHA_GATEWAY from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT from homeassistant.const import Platform @@ -62,14 +63,25 @@ async def test_diagnostics_for_config_entry( ) -> None: """Test diagnostics for config entry.""" await zha_device_joined(zigpy_device) - diagnostics_data = await get_diagnostics_for_config_entry( - hass, hass_client, config_entry - ) - assert diagnostics_data + + gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + scan = {c: c for c in range(11, 26 + 1)} + + with patch.object(gateway.application_controller, "energy_scan", return_value=scan): + diagnostics_data = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + for key in CONFIG_ENTRY_DIAGNOSTICS_KEYS: assert key in diagnostics_data assert diagnostics_data[key] is not None + # Energy scan results are presented as a percentage. JSON object keys also must be + # strings, not integers. + assert diagnostics_data["energy_scan"] == { + str(k): 100 * v / 255 for k, v in scan.items() + } + async def test_diagnostics_for_device( hass: HomeAssistant, From b3348c3e6ffbfc04bdd5e695cd0342793d2ce296 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:39:08 -0400 Subject: [PATCH 0415/1165] Bump zwave-js-server-python to 0.47.3 (#90606) * Bump zwave-js-server-python to 0.47.2 * Bump zwave-js-server-python to 0.47.3 --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 5fb7726577..d41ee0272a 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.3"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 9843965125..ee45c2b387 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2728,7 +2728,7 @@ zigpy==0.54.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a249c7c7db..5e9d6cc001 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1953,7 +1953,7 @@ zigpy-znp==0.10.0 zigpy==0.54.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 From 03f085d7be71d3a08733dc10949a498168479088 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 15:41:37 -0400 Subject: [PATCH 0416/1165] Bumped version to 2023.4.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b47e1d9fb5..38c243997a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 76c1f18616..f000a293db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b2" +version = "2023.4.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f4c341253b98df575e00e9f10e5868af1bd833af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 11:27:55 -1000 Subject: [PATCH 0417/1165] Avoid sorting domain/all states in templates (#90608) --- homeassistant/helpers/template.py | 6 ++-- tests/helpers/test_event.py | 4 ++- tests/helpers/test_template.py | 47 ++++++++++++++++++------------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 36e0a597b8..8e5951488b 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -13,7 +13,7 @@ from functools import cache, lru_cache, partial, wraps import json import logging import math -from operator import attrgetter, contains +from operator import contains import pathlib import random import re @@ -983,7 +983,7 @@ def _state_generator( hass: HomeAssistant, domain: str | None ) -> Generator[TemplateState, None, None]: """State generator for a domain or all states.""" - for state in sorted(hass.states.async_all(domain), key=attrgetter("entity_id")): + for state in hass.states.async_all(domain): yield _template_state_no_collect(hass, state) @@ -1097,7 +1097,7 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: _collect_state(hass, entity_id) found[entity_id] = entity - return sorted(found.values(), key=lambda a: a.entity_id) + return list(found.values()) def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 7e84d634ef..a482e1b63b 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3043,7 +3043,9 @@ async def test_async_track_template_result_multiple_templates_mixing_domain( template_1 = Template("{{ states.switch.test.state == 'on' }}") template_2 = Template("{{ states.switch.test.state == 'on' }}") template_3 = Template("{{ states.switch.test.state == 'off' }}") - template_4 = Template("{{ states.switch | map(attribute='entity_id') | list }}") + template_4 = Template( + "{{ states.switch | sort(attribute='entity_id') | map(attribute='entity_id') | list }}" + ) refresh_runs = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f185191d1b..4b3b9488bd 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -185,7 +185,7 @@ def test_raise_exception_on_error(hass: HomeAssistant) -> None: def test_iterating_all_states(hass: HomeAssistant) -> None: """Test iterating all states.""" - tmpl_str = "{% for state in states %}{{ state.state }}{% endfor %}" + tmpl_str = "{% for state in states | sort(attribute='entity_id') %}{{ state.state }}{% endfor %}" info = render_to_info(hass, tmpl_str) assert_result_info(info, "", all_states=True) @@ -2511,20 +2511,22 @@ async def test_expand(hass: HomeAssistant) -> None: hass.states.async_set("test.object", "happy") info = render_to_info( - hass, "{{ expand('test.object') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('test.object') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", ["test.object"]) assert info.rate_limit is None info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", ["group.new_group"]) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", [], ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2535,13 +2537,14 @@ async def test_expand(hass: HomeAssistant) -> None: info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"group.new_group", "test.object"}) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"test.object"}, ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2550,7 +2553,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ expand('group.new_group', 'test.object')" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2559,7 +2562,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ ['group.new_group', 'test.object'] | expand" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2579,7 +2582,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ states.group.power_sensors.attributes.entity_id | expand " - "| map(attribute='state')|map('float')|sum }}" + "| sort(attribute='entity_id') | map(attribute='state')|map('float')|sum }}" ), ) assert_result_info( @@ -2607,7 +2610,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('light.grouped') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('light.grouped') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2629,7 +2633,8 @@ async def test_expand(hass: HomeAssistant) -> None: }, ) info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2644,7 +2649,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2659,7 +2665,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2709,7 +2716,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "", ["light.hue_5678"]) @@ -2721,7 +2728,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "light.hue_5678", ["light.hue_5678"]) @@ -2743,7 +2750,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info( @@ -3384,7 +3391,7 @@ def test_async_render_to_info_with_complex_branching(hass: HomeAssistant) -> Non {% elif states.light.a == "on" %} {{ states[domain] | list }} {% elif states('light.b') == "on" %} - {{ states[otherdomain] | map(attribute='entity_id') | list }} + {{ states[otherdomain] | sort(attribute='entity_id') | map(attribute='entity_id') | list }} {% elif states.light.a == "on" %} {{ states["nonexist"] | list }} {% else %} @@ -4205,7 +4212,7 @@ async def test_lights(hass: HomeAssistant) -> None: """Test we can sort lights.""" tmpl = """ - {% set lights_on = states.light|selectattr('state','eq','on')|map(attribute='name')|list %} + {% set lights_on = states.light|selectattr('state','eq','on')|sort(attribute='entity_id')|map(attribute='name')|list %} {% if lights_on|length == 0 %} No lights on. Sleep well.. {% elif lights_on|length == 1 %} @@ -4308,7 +4315,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: tpl = template.Template( ( "{{ states | selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | list | join(', ') }}" ), hass, ) @@ -4318,7 +4325,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: ( "{{ states.light " "| selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list " + "| sort(attribute='entity_id') | map(attribute='entity_id') | list " "| join(', ') }}" ), hass, From 3e59687902ad3a1819d43ec3cea54905094bc941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 31 Mar 2023 23:57:39 +0200 Subject: [PATCH 0418/1165] Only limit stats to started add-ons (#90611) --- homeassistant/components/hassio/__init__.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index d5449cf927..e6ff9888b1 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -870,23 +870,25 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.hassio.get_os_info(), ) - addons = [ - addon - for addon in self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) - if addon[ATTR_STATE] == ATTR_STARTED + all_addons = self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) + started_addons = [ + addon for addon in all_addons if addon[ATTR_STATE] == ATTR_STARTED ] stats_data = await asyncio.gather( - *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in started_addons] ) self.hass.data[DATA_ADDONS_STATS] = dict(stats_data) self.hass.data[DATA_ADDONS_CHANGELOGS] = dict( await asyncio.gather( - *[self._update_addon_changelog(addon[ATTR_SLUG]) for addon in addons] + *[ + self._update_addon_changelog(addon[ATTR_SLUG]) + for addon in all_addons + ] ) ) self.hass.data[DATA_ADDONS_INFO] = dict( await asyncio.gather( - *[self._update_addon_info(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_info(addon[ATTR_SLUG]) for addon in all_addons] ) ) From 3e94f2a5029ae9d675ba9832a3e0e5cfe3b753fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 15:15:36 -1000 Subject: [PATCH 0419/1165] Small speed up to _collection_changed (#90621) attrgetter builds a fast method which happens in native code https://github.com/python/cpython/blob/4664a7cf689946f0c9854cadee7c6aa9c276a8cf/Modules/_operator.c#L1413 --- homeassistant/helpers/collection.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 437cd41871..9da6f84207 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass from itertools import groupby import logging +from operator import attrgetter from typing import Any, cast import voluptuous as vol @@ -410,9 +411,8 @@ def sync_entity_lifecycle( # Create a new bucket every time we have a different change type # to ensure operations happen in order. We only group # the same change type. - for _, grouped in groupby( - change_sets, lambda change_set: change_set.change_type - ): + groupby_key = attrgetter("change_type") + for _, grouped in groupby(change_sets, groupby_key): new_entities = [ entity for entity in await asyncio.gather( From 44b35fea47107d45b2b18d0a6453d4936ac4bfc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 15:18:29 -1000 Subject: [PATCH 0420/1165] Speed up entity filter when there are many glob matchers (#90615) * Speed up entity filter when there are many glob matchers Since we do no care about which glob matches we can combine all the translated globs into a single regex which reduces the overhead * delete unused code * preen --- homeassistant/helpers/entityfilter.py | 63 ++++++++++++--------------- tests/helpers/test_entityfilter.py | 2 +- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index d8b827bd24..057e8f0955 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -33,26 +33,20 @@ class EntityFilter: self._exclude_e = set(config[CONF_EXCLUDE_ENTITIES]) self._include_d = set(config[CONF_INCLUDE_DOMAINS]) self._exclude_d = set(config[CONF_EXCLUDE_DOMAINS]) - self._include_eg = _convert_globs_to_pattern_list( - config[CONF_INCLUDE_ENTITY_GLOBS] - ) - self._exclude_eg = _convert_globs_to_pattern_list( - config[CONF_EXCLUDE_ENTITY_GLOBS] - ) + self._include_eg = _convert_globs_to_pattern(config[CONF_INCLUDE_ENTITY_GLOBS]) + self._exclude_eg = _convert_globs_to_pattern(config[CONF_EXCLUDE_ENTITY_GLOBS]) self._filter: Callable[[str], bool] | None = None def explicitly_included(self, entity_id: str) -> bool: """Check if an entity is explicitly included.""" return entity_id in self._include_e or ( - bool(self._include_eg) - and _test_against_patterns(self._include_eg, entity_id) + bool(self._include_eg and self._include_eg.match(entity_id)) ) def explicitly_excluded(self, entity_id: str) -> bool: """Check if an entity is explicitly excluded.""" return entity_id in self._exclude_e or ( - bool(self._exclude_eg) - and _test_against_patterns(self._exclude_eg, entity_id) + bool(self._exclude_eg and self._exclude_eg.match(entity_id)) ) def __call__(self, entity_id: str) -> bool: @@ -140,19 +134,22 @@ INCLUDE_EXCLUDE_FILTER_SCHEMA = vol.All( ) -def _glob_to_re(glob: str) -> re.Pattern[str]: - """Translate and compile glob string into pattern.""" - return re.compile(fnmatch.translate(glob)) - - -def _test_against_patterns(patterns: list[re.Pattern[str]], entity_id: str) -> bool: - """Test entity against list of patterns, true if any match.""" - return any(pattern.match(entity_id) for pattern in patterns) - - -def _convert_globs_to_pattern_list(globs: list[str] | None) -> list[re.Pattern[str]]: +def _convert_globs_to_pattern(globs: list[str] | None) -> re.Pattern[str] | None: """Convert a list of globs to a re pattern list.""" - return list(map(_glob_to_re, set(globs or []))) + if globs is None: + return None + + translated_patterns: list[str] = [] + for glob in set(globs): + if pattern := fnmatch.translate(glob): + translated_patterns.append(pattern) + + if not translated_patterns: + return None + + inner = "|".join(translated_patterns) + combined = f"(?:{inner})" + return re.compile(combined) def generate_filter( @@ -169,8 +166,8 @@ def generate_filter( set(include_entities), set(exclude_domains), set(exclude_entities), - _convert_globs_to_pattern_list(include_entity_globs), - _convert_globs_to_pattern_list(exclude_entity_globs), + _convert_globs_to_pattern(include_entity_globs), + _convert_globs_to_pattern(exclude_entity_globs), ) @@ -179,8 +176,8 @@ def _generate_filter_from_sets_and_pattern_lists( include_e: set[str], exclude_d: set[str], exclude_e: set[str], - include_eg: list[re.Pattern[str]], - exclude_eg: list[re.Pattern[str]], + include_eg: re.Pattern[str] | None, + exclude_eg: re.Pattern[str] | None, ) -> Callable[[str], bool]: """Generate a filter from pre-comuted sets and pattern lists.""" have_exclude = bool(exclude_e or exclude_d or exclude_eg) @@ -191,7 +188,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in include_e or domain in include_d - or (bool(include_eg) and _test_against_patterns(include_eg, entity_id)) + or (bool(include_eg and include_eg.match(entity_id))) ) def entity_excluded(domain: str, entity_id: str) -> bool: @@ -199,7 +196,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in exclude_e or domain in exclude_d - or (bool(exclude_eg) and _test_against_patterns(exclude_eg, entity_id)) + or (bool(exclude_eg and exclude_eg.match(entity_id))) ) # Case 1 - No filter @@ -249,12 +246,10 @@ def _generate_filter_from_sets_and_pattern_lists( return entity_id in include_e or ( entity_id not in exclude_e and ( - (include_eg and _test_against_patterns(include_eg, entity_id)) + bool(include_eg and include_eg.match(entity_id)) or ( split_entity_id(entity_id)[0] in include_d - and not ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ) + and not (exclude_eg and exclude_eg.match(entity_id)) ) ) ) @@ -272,9 +267,7 @@ def _generate_filter_from_sets_and_pattern_lists( def entity_filter_4b(entity_id: str) -> bool: """Return filter function for case 4b.""" domain = split_entity_id(entity_id)[0] - if domain in exclude_d or ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ): + if domain in exclude_d or bool(exclude_eg and exclude_eg.match(entity_id)): return entity_id in include_e return entity_id not in exclude_e diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py index 9888704702..2141c28691 100644 --- a/tests/helpers/test_entityfilter.py +++ b/tests/helpers/test_entityfilter.py @@ -369,7 +369,7 @@ def test_filter_schema_include_exclude() -> None: assert not filt.empty_filter -def test_exlictly_included() -> None: +def test_explicitly_included() -> None: """Test if an entity is explicitly included.""" conf = { "include": { From 90d81e9844747fd5ea75e894e7c7b998fb54532a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 31 Mar 2023 22:55:07 -0500 Subject: [PATCH 0421/1165] Use webrcvad to detect silence in pipelines (#90610) * Add webrtcvad requirement * Use webrcvad for voice command segmenting * Add vad test --- .../components/voice_assistant/manifest.json | 3 +- .../components/voice_assistant/vad.py | 128 ++++++++++++++++++ .../voice_assistant/websocket_api.py | 40 +----- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/voice_assistant/test_vad.py | 38 ++++++ .../voice_assistant/test_websocket.py | 2 +- 7 files changed, 180 insertions(+), 37 deletions(-) create mode 100644 homeassistant/components/voice_assistant/vad.py create mode 100644 tests/components/voice_assistant/test_vad.py diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json index 644c49e945..f4a17bf52e 100644 --- a/homeassistant/components/voice_assistant/manifest.json +++ b/homeassistant/components/voice_assistant/manifest.json @@ -5,5 +5,6 @@ "dependencies": ["conversation", "stt", "tts"], "documentation": "https://www.home-assistant.io/integrations/voice_assistant", "iot_class": "local_push", - "quality_scale": "internal" + "quality_scale": "internal", + "requirements": ["webrtcvad==2.0.10"] } diff --git a/homeassistant/components/voice_assistant/vad.py b/homeassistant/components/voice_assistant/vad.py new file mode 100644 index 0000000000..e86579b975 --- /dev/null +++ b/homeassistant/components/voice_assistant/vad.py @@ -0,0 +1,128 @@ +"""Voice activity detection.""" +from dataclasses import dataclass, field + +import webrtcvad + +_SAMPLE_RATE = 16000 + + +@dataclass +class VoiceCommandSegmenter: + """Segments an audio stream into voice commands using webrtcvad.""" + + vad_mode: int = 3 + """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" + + vad_frames: int = 480 # 30 ms + """Must be 10, 20, or 30 ms at 16Khz.""" + + speech_seconds: float = 0.3 + """Seconds of speech before voice command has started.""" + + silence_seconds: float = 0.5 + """Seconds of silence after voice command has ended.""" + + timeout_seconds: float = 15.0 + """Maximum number of seconds before stopping with timeout=True.""" + + reset_seconds: float = 1.0 + """Seconds before reset start/stop time counters.""" + + _in_command: bool = False + """True if inside voice command.""" + + _speech_seconds_left: float = 0.0 + """Seconds left before considering voice command as started.""" + + _silence_seconds_left: float = 0.0 + """Seconds left before considering voice command as stopped.""" + + _timeout_seconds_left: float = 0.0 + """Seconds left before considering voice command timed out.""" + + _reset_seconds_left: float = 0.0 + """Seconds left before resetting start/stop time counters.""" + + _vad: webrtcvad.Vad = None + _audio_buffer: bytes = field(default_factory=bytes) + _bytes_per_chunk: int = 480 * 2 # 16-bit samples + _seconds_per_chunk: float = 0.03 # 30 ms + + def __post_init__(self): + """Initialize VAD.""" + self._vad = webrtcvad.Vad(self.vad_mode) + self._bytes_per_chunk = self.vad_frames * 2 + self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE + self.reset() + + def reset(self): + """Reset all counters and state.""" + self._audio_buffer = b"" + self._speech_seconds_left = self.speech_seconds + self._silence_seconds_left = self.silence_seconds + self._timeout_seconds_left = self.timeout_seconds + self._reset_seconds_left = self.reset_seconds + self._in_command = False + + def process(self, samples: bytes) -> bool: + """Process a 16-bit 16Khz mono audio samples. + + Returns False when command is done. + """ + self._audio_buffer += samples + + # Process in 10, 20, or 30 ms chunks. + num_chunks = len(self._audio_buffer) // self._bytes_per_chunk + for chunk_idx in range(num_chunks): + chunk_offset = chunk_idx * self._bytes_per_chunk + chunk = self._audio_buffer[ + chunk_offset : chunk_offset + self._bytes_per_chunk + ] + if not self._process_chunk(chunk): + self.reset() + return False + + if num_chunks > 0: + # Remove from buffer + self._audio_buffer = self._audio_buffer[ + num_chunks * self._bytes_per_chunk : + ] + + return True + + def _process_chunk(self, chunk: bytes) -> bool: + """Process a single chunk of 16-bit 16Khz mono audio. + + Returns False when command is done. + """ + is_speech = self._vad.is_speech(chunk, _SAMPLE_RATE) + + self._timeout_seconds_left -= self._seconds_per_chunk + if self._timeout_seconds_left <= 0: + return False + + if not self._in_command: + if is_speech: + self._reset_seconds_left = self.reset_seconds + self._speech_seconds_left -= self._seconds_per_chunk + if self._speech_seconds_left <= 0: + # Inside voice command + self._in_command = True + else: + # Reset if enough silence + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._speech_seconds_left = self.speech_seconds + else: + if not is_speech: + self._reset_seconds_left = self.reset_seconds + self._silence_seconds_left -= self._seconds_per_chunk + if self._silence_seconds_left <= 0: + return False + else: + # Reset if enough speech + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._silence_seconds_left = self.silence_seconds + + return True diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index aa295ad5c6..718989f661 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -20,15 +20,12 @@ from .pipeline import ( PipelineStage, async_get_pipeline, ) +from .vad import VoiceCommandSegmenter DEFAULT_TIMEOUT = 30 _LOGGER = logging.getLogger(__name__) -_VAD_ENERGY_THRESHOLD = 1000 -_VAD_SPEECH_FRAMES = 25 -_VAD_SILENCE_FRAMES = 25 - @callback def async_register_websocket_api(hass: HomeAssistant) -> None: @@ -36,17 +33,6 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_run) -def _get_debiased_energy(audio_data: bytes, width: int = 2) -> float: - """Compute RMS of debiased audio.""" - energy = -audioop.rms(audio_data, width) - energy_bytes = bytes([energy & 0xFF, (energy >> 8) & 0xFF]) - debiased_energy = audioop.rms( - audioop.add(audio_data, energy_bytes * (len(audio_data) // width), width), width - ) - - return debiased_energy - - @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", @@ -105,30 +91,14 @@ async def websocket_run( async def stt_stream(): state = None - speech_count = 0 - in_voice_command = False + segmenter = VoiceCommandSegmenter() # Yield until we receive an empty chunk while chunk := await audio_queue.get(): chunk, state = audioop.ratecv(chunk, 2, 1, 44100, 16000, state) - is_speech = _get_debiased_energy(chunk) > _VAD_ENERGY_THRESHOLD - - if in_voice_command: - if is_speech: - speech_count += 1 - else: - speech_count -= 1 - - if speech_count <= -_VAD_SILENCE_FRAMES: - _LOGGER.info("Voice command stopped") - break - else: - if is_speech: - speech_count += 1 - - if speech_count >= _VAD_SPEECH_FRAMES: - in_voice_command = True - _LOGGER.info("Voice command started") + if not segmenter.process(chunk): + # Voice command is finished + break yield chunk diff --git a/requirements_all.txt b/requirements_all.txt index 935d7bdb69..e01e1c23e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2619,6 +2619,9 @@ waterfurnace==1.1.0 # homeassistant.components.cisco_webex_teams webexteamssdk==1.1.1 +# homeassistant.components.voice_assistant +webrtcvad==2.0.10 + # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27d71f3d56..89b84246ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1877,6 +1877,9 @@ wallbox==0.4.12 # homeassistant.components.folder_watcher watchdog==2.3.1 +# homeassistant.components.voice_assistant +webrtcvad==2.0.10 + # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.2 diff --git a/tests/components/voice_assistant/test_vad.py b/tests/components/voice_assistant/test_vad.py new file mode 100644 index 0000000000..4285f78d51 --- /dev/null +++ b/tests/components/voice_assistant/test_vad.py @@ -0,0 +1,38 @@ +"""Tests for webrtcvad voice command segmenter.""" +from unittest.mock import patch + +from homeassistant.components.voice_assistant.vad import VoiceCommandSegmenter + +_ONE_SECOND = 16000 * 2 # 16Khz 16-bit + + +def test_silence() -> None: + """Test that 3 seconds of silence does not trigger a voice command.""" + segmenter = VoiceCommandSegmenter() + + # True return value indicates voice command has not finished + assert segmenter.process(bytes(_ONE_SECOND * 3)) + + +def test_speech() -> None: + """Test that silence + speech + silence triggers a voice command.""" + + def is_speech(self, chunk, sample_rate): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + with patch( + "webrtcvad.Vad.is_speech", + new=is_speech, + ): + segmenter = VoiceCommandSegmenter() + + # silence + assert segmenter.process(bytes(_ONE_SECOND)) + + # "speech" + assert segmenter.process(bytes([255] * _ONE_SECOND)) + + # silence + # False return value indicates voice command is finished + assert not segmenter.process(bytes(_ONE_SECOND)) diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index ce87655032..54fe51a7a2 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -75,7 +75,7 @@ class MockSTT: hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, - ) -> tts.Provider: + ) -> stt.Provider: """Set up a mock speech component.""" return MockSttProvider(hass, _TRANSCRIPT) From e94c11371d49d61615ece0f4441d75f9dec9d1bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 04:22:26 -1000 Subject: [PATCH 0422/1165] Bump securetar to 2023.3.0 (#90612) changelog: https://github.com/pvizeli/securetar/compare/2022.02.0...2023.3.0 --- homeassistant/components/backup/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 7b495912f5..fb7e9eff78 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2022.2.0"] + "requirements": ["securetar==2023.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e01e1c23e8..b91775525d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2306,7 +2306,7 @@ screenlogicpy==0.8.2 scsgate==0.1.0 # homeassistant.components.backup -securetar==2022.2.0 +securetar==2023.3.0 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89b84246ff..709155b3ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1645,7 +1645,7 @@ scapy==2.5.0 screenlogicpy==0.8.2 # homeassistant.components.backup -securetar==2022.2.0 +securetar==2023.3.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense From f1fa63281e850e75041754f016a0612b557eda92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 04:24:02 -1000 Subject: [PATCH 0423/1165] Adjust context id variable names in the logbook processor to improve readability (#90617) Adjust some variable names in the logbook process to improve readablity There were some places were we used context_id that should have been context_id_bin --- homeassistant/components/logbook/processor.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 32301e9835..7d0eec5eb6 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -204,7 +204,7 @@ def _humanify( # Process rows for row in rows: - context_id = context_lookup.memorize(row) + context_id_bin = context_lookup.memorize(row) if row.context_only: continue event_type = row.event_type @@ -232,7 +232,7 @@ def _humanify( if icon := row.icon or row.old_format_icon: data[LOGBOOK_ENTRY_ICON] = icon - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type in external_events: @@ -240,7 +240,7 @@ def _humanify( data = describe_event(event_cache.get(row)) data[LOGBOOK_ENTRY_WHEN] = format_time(row) data[LOGBOOK_ENTRY_DOMAIN] = domain - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type == EVENT_LOGBOOK_ENTRY: @@ -259,7 +259,7 @@ def _humanify( LOGBOOK_ENTRY_DOMAIN: entry_domain, LOGBOOK_ENTRY_ENTITY_ID: entry_entity_id, } - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data @@ -302,11 +302,11 @@ class ContextAugmenter: self.include_entity_name = logbook_run.include_entity_name def _get_context_row( - self, context_id: bytes | None, row: Row | EventAsRow + self, context_id_bin: bytes | None, row: Row | EventAsRow ) -> Row | EventAsRow | None: """Get the context row from the id or row context.""" - if context_id: - return self.context_lookup.get(context_id) + if context_id_bin: + return self.context_lookup.get(context_id_bin) if (context := getattr(row, "context", None)) is not None and ( origin_event := context.origin_event ) is not None: @@ -314,13 +314,13 @@ class ContextAugmenter: return None def augment( - self, data: dict[str, Any], row: Row | EventAsRow, context_id: bytes | None + self, data: dict[str, Any], row: Row | EventAsRow, context_id_bin: bytes | None ) -> None: """Augment data from the row and cache.""" if context_user_id_bin := row.context_user_id_bin: data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(context_user_id_bin) - if not (context_row := self._get_context_row(context_id, row)): + if not (context_row := self._get_context_row(context_id_bin, row)): return if _rows_match(row, context_row): From 9cab05c4b91262c34e4d3317ee1ce23aef73adb6 Mon Sep 17 00:00:00 2001 From: nono Date: Sat, 1 Apr 2023 17:45:24 +0200 Subject: [PATCH 0424/1165] Fix Rest switch init was not retrying if unreachable at setup (#90627) * Fix Rest switch init was not retrying if unreachable at setup * pass error log to platformnotready prevents spamming the same message in logs. --- homeassistant/components/rest/switch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index cda35d1f91..9e016db037 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,8 +98,8 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("No route to resource/endpoint: %s", resource) + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc class RestSwitch(TemplateEntity, SwitchEntity): From b47ac524eadfed9c6be988c48792098b99677b86 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 1 Apr 2023 17:47:31 +0200 Subject: [PATCH 0425/1165] Use async_timeout instead of asyncio.wait_for (#90496) * Use async_timeout instead of asyncio.wait_for * fix imports * fix imports * break out Event.wait patch * Update tests/components/reolink/conftest.py Co-authored-by: Martin Hjelmare * Simplify --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/reolink/host.py | 8 +++++-- tests/components/reolink/conftest.py | 9 ++++++-- tests/components/reolink/test_config_flow.py | 4 +++- tests/components/reolink/test_init.py | 22 ++++++++++++-------- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f781074648..e6c9034322 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -8,6 +8,7 @@ from typing import Any import aiohttp from aiohttp.web import Request +import async_timeout from reolink_aio.api import Host from reolink_aio.exceptions import ReolinkError, SubscriptionError @@ -23,6 +24,7 @@ from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin DEFAULT_TIMEOUT = 60 +FIRST_ONVIF_TIMEOUT = 15 SUBSCRIPTION_RENEW_THRESHOLD = 300 _LOGGER = logging.getLogger(__name__) @@ -146,11 +148,13 @@ class ReolinkHost: "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url ) try: - await asyncio.wait_for(self._webhook_reachable.wait(), timeout=15) + async with async_timeout.timeout(FIRST_ONVIF_TIMEOUT): + await self._webhook_reachable.wait() except asyncio.TimeoutError: _LOGGER.debug( - "Did not receive initial ONVIF state on webhook '%s' after 15 seconds", + "Did not receive initial ONVIF state on webhook '%s' after %i seconds", self._webhook_url, + FIRST_ONVIF_TIMEOUT, ) ir.async_create_issue( self._hass, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index be748ef2c4..d36aea905f 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -39,8 +39,6 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None with patch( "homeassistant.components.reolink.host.webhook.async_register", return_value=True, - ), patch( - "homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock() ), patch( "homeassistant.components.reolink.host.Host", autospec=True ) as host_mock_class: @@ -65,6 +63,13 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None yield host_mock +@pytest.fixture +def reolink_ONVIF_wait() -> Generator[None, None, None]: + """Mock reolink connection.""" + with patch("homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock()): + yield + + @pytest.fixture def reolink_platforms(mock_get_source_ip: None) -> Generator[None, None, None]: """Mock reolink entry setup.""" diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index b3abb793a9..7d25fd6281 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -28,7 +28,9 @@ from .conftest import ( from tests.common import MockConfigEntry -pytestmark = pytest.mark.usefixtures("mock_setup_entry", "reolink_connect") +pytestmark = pytest.mark.usefixtures( + "mock_setup_entry", "reolink_connect", "reolink_ONVIF_wait" +) async def test_config_flow_manual_success(hass: HomeAssistant) -> None: diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 57d0dbd7cb..8dd6db270f 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,5 +1,4 @@ """Test the Reolink init.""" -import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -55,6 +54,7 @@ pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") async def test_failures_parametrized( hass: HomeAssistant, reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, config_entry: MockConfigEntry, attr: str, value: Any, @@ -71,7 +71,10 @@ async def test_failures_parametrized( async def test_entry_reloading( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -88,7 +91,7 @@ async def test_entry_reloading( async def test_no_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock ) -> None: """Test no repairs issue is raised when http local url is used.""" await async_process_ha_core_config( @@ -106,7 +109,7 @@ async def test_no_repair_issue( async def test_https_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock ) -> None: """Test repairs issue is raised when https local url is used.""" await async_process_ha_core_config( @@ -125,6 +128,7 @@ async def test_port_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, protocol: str, ) -> None: """Test repairs issue is raised when auto enable of ports fails.""" @@ -144,10 +148,7 @@ async def test_webhook_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test repairs issue is raised when the webhook url is unreachable.""" - with patch( - "homeassistant.components.reolink.host.asyncio.Event.wait", - AsyncMock(side_effect=asyncio.TimeoutError()), - ): + with patch("homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -156,7 +157,10 @@ async def test_webhook_repair_issue( async def test_firmware_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, ) -> None: """Test firmware issue is raised when too old firmware is used.""" reolink_connect.sw_version_update_required = True From 00a4279d64ffb3dcd6e10a20d64b31788d3d7694 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 09:14:59 -1000 Subject: [PATCH 0426/1165] Speed up backups (#90613) --- homeassistant/components/backup/manager.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 69df310bd5..f48a71a78c 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -23,6 +23,8 @@ from homeassistant.util.json import json_loads_object from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER +BUF_SIZE = 2**20 * 4 # 4MB + @dataclass class Backup: @@ -99,7 +101,7 @@ class BackupManager: backups: dict[str, Backup] = {} for backup_path in self.backup_dir.glob("*.tar"): try: - with tarfile.open(backup_path, "r:") as backup_file: + with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file: if data_file := backup_file.extractfile("./backup.json"): data = json_loads_object(data_file.read()) backup = Backup( @@ -227,7 +229,7 @@ class BackupManager: self.backup_dir.mkdir() with TemporaryDirectory() as tmp_dir, SecureTarFile( - tar_file_path, "w", gzip=False + tar_file_path, "w", gzip=False, bufsize=BUF_SIZE ) as tar_file: tmp_dir_path = Path(tmp_dir) save_json( @@ -237,6 +239,7 @@ class BackupManager: with SecureTarFile( tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(), "w", + bufsize=BUF_SIZE, ) as core_tar: atomic_contents_add( tar_file=core_tar, From 8263c3de2358548a14b75c714aa7db22d35fbb1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 09:15:17 -1000 Subject: [PATCH 0427/1165] Bump zeroconf to 0.51.0 (#90622) * Bump zeroconf to 0.50.0 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.47.4...0.50.0 * bump to 51 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index b7a643bb46..36c2fcc127 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.47.4"] + "requirements": ["zeroconf==0.51.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ab00d9ca81..1a5bc9407a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.47.4 +zeroconf==0.51.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index b91775525d..a7617899c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 709155b3ee..be8342a51d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 9965d9d81db8f83c43c3a6f965c74ba45043b18b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Apr 2023 21:17:53 +0200 Subject: [PATCH 0428/1165] Fix mqtt device_tracker is not reloading yaml (#90639) --- homeassistant/components/mqtt/const.py | 1 + tests/components/mqtt/test_device_tracker.py | 21 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index bb6b8ed497..41fd353359 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -113,6 +113,7 @@ RELOADABLE_PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DEVICE_TRACKER, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index a8c45f8cd7..a0ac73953b 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -10,10 +10,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .test_common import help_test_setting_blocked_attribute_via_mqtt_json_message +from .test_common import ( + help_test_reloadable, + help_test_setting_blocked_attribute_via_mqtt_json_message, +) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator, WebSocketGenerator +from tests.typing import ( + MqttMockHAClientGenerator, + MqttMockPahoClient, + WebSocketGenerator, +) DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -603,3 +610,13 @@ async def test_setup_with_modern_schema( dev_id = "jan" entity_id = f"{device_tracker.DOMAIN}.{dev_id}" assert hass.states.get(entity_id) is not None + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = device_tracker.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) From 2852fe6786f70c204c6b3f191682913912bc7506 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 1 Apr 2023 21:21:51 +0200 Subject: [PATCH 0429/1165] Update frontend to 20230401.0 (#90646) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 114760923e..6468bd6daa 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==20230331.0"] + "requirements": ["home-assistant-frontend==20230401.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1a5bc9407a..8c49308503 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index a7617899c4..fedca63529 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be8342a51d..d637999fb3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 6242dd2214c42ca403c2932f1b3f748cced7a069 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 11:27:55 -1000 Subject: [PATCH 0430/1165] Avoid sorting domain/all states in templates (#90608) --- homeassistant/helpers/template.py | 6 ++-- tests/helpers/test_event.py | 4 ++- tests/helpers/test_template.py | 47 ++++++++++++++++++------------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 36e0a597b8..8e5951488b 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -13,7 +13,7 @@ from functools import cache, lru_cache, partial, wraps import json import logging import math -from operator import attrgetter, contains +from operator import contains import pathlib import random import re @@ -983,7 +983,7 @@ def _state_generator( hass: HomeAssistant, domain: str | None ) -> Generator[TemplateState, None, None]: """State generator for a domain or all states.""" - for state in sorted(hass.states.async_all(domain), key=attrgetter("entity_id")): + for state in hass.states.async_all(domain): yield _template_state_no_collect(hass, state) @@ -1097,7 +1097,7 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: _collect_state(hass, entity_id) found[entity_id] = entity - return sorted(found.values(), key=lambda a: a.entity_id) + return list(found.values()) def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 7e84d634ef..a482e1b63b 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3043,7 +3043,9 @@ async def test_async_track_template_result_multiple_templates_mixing_domain( template_1 = Template("{{ states.switch.test.state == 'on' }}") template_2 = Template("{{ states.switch.test.state == 'on' }}") template_3 = Template("{{ states.switch.test.state == 'off' }}") - template_4 = Template("{{ states.switch | map(attribute='entity_id') | list }}") + template_4 = Template( + "{{ states.switch | sort(attribute='entity_id') | map(attribute='entity_id') | list }}" + ) refresh_runs = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f185191d1b..4b3b9488bd 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -185,7 +185,7 @@ def test_raise_exception_on_error(hass: HomeAssistant) -> None: def test_iterating_all_states(hass: HomeAssistant) -> None: """Test iterating all states.""" - tmpl_str = "{% for state in states %}{{ state.state }}{% endfor %}" + tmpl_str = "{% for state in states | sort(attribute='entity_id') %}{{ state.state }}{% endfor %}" info = render_to_info(hass, tmpl_str) assert_result_info(info, "", all_states=True) @@ -2511,20 +2511,22 @@ async def test_expand(hass: HomeAssistant) -> None: hass.states.async_set("test.object", "happy") info = render_to_info( - hass, "{{ expand('test.object') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('test.object') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", ["test.object"]) assert info.rate_limit is None info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", ["group.new_group"]) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", [], ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2535,13 +2537,14 @@ async def test_expand(hass: HomeAssistant) -> None: info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"group.new_group", "test.object"}) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"test.object"}, ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2550,7 +2553,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ expand('group.new_group', 'test.object')" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2559,7 +2562,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ ['group.new_group', 'test.object'] | expand" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2579,7 +2582,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ states.group.power_sensors.attributes.entity_id | expand " - "| map(attribute='state')|map('float')|sum }}" + "| sort(attribute='entity_id') | map(attribute='state')|map('float')|sum }}" ), ) assert_result_info( @@ -2607,7 +2610,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('light.grouped') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('light.grouped') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2629,7 +2633,8 @@ async def test_expand(hass: HomeAssistant) -> None: }, ) info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2644,7 +2649,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2659,7 +2665,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2709,7 +2716,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "", ["light.hue_5678"]) @@ -2721,7 +2728,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "light.hue_5678", ["light.hue_5678"]) @@ -2743,7 +2750,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info( @@ -3384,7 +3391,7 @@ def test_async_render_to_info_with_complex_branching(hass: HomeAssistant) -> Non {% elif states.light.a == "on" %} {{ states[domain] | list }} {% elif states('light.b') == "on" %} - {{ states[otherdomain] | map(attribute='entity_id') | list }} + {{ states[otherdomain] | sort(attribute='entity_id') | map(attribute='entity_id') | list }} {% elif states.light.a == "on" %} {{ states["nonexist"] | list }} {% else %} @@ -4205,7 +4212,7 @@ async def test_lights(hass: HomeAssistant) -> None: """Test we can sort lights.""" tmpl = """ - {% set lights_on = states.light|selectattr('state','eq','on')|map(attribute='name')|list %} + {% set lights_on = states.light|selectattr('state','eq','on')|sort(attribute='entity_id')|map(attribute='name')|list %} {% if lights_on|length == 0 %} No lights on. Sleep well.. {% elif lights_on|length == 1 %} @@ -4308,7 +4315,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: tpl = template.Template( ( "{{ states | selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | list | join(', ') }}" ), hass, ) @@ -4318,7 +4325,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: ( "{{ states.light " "| selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list " + "| sort(attribute='entity_id') | map(attribute='entity_id') | list " "| join(', ') }}" ), hass, From d5d5bb0732b8cf131e4d7934552c6522259da744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 31 Mar 2023 23:57:39 +0200 Subject: [PATCH 0431/1165] Only limit stats to started add-ons (#90611) --- homeassistant/components/hassio/__init__.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index d5449cf927..e6ff9888b1 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -870,23 +870,25 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.hassio.get_os_info(), ) - addons = [ - addon - for addon in self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) - if addon[ATTR_STATE] == ATTR_STARTED + all_addons = self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) + started_addons = [ + addon for addon in all_addons if addon[ATTR_STATE] == ATTR_STARTED ] stats_data = await asyncio.gather( - *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in started_addons] ) self.hass.data[DATA_ADDONS_STATS] = dict(stats_data) self.hass.data[DATA_ADDONS_CHANGELOGS] = dict( await asyncio.gather( - *[self._update_addon_changelog(addon[ATTR_SLUG]) for addon in addons] + *[ + self._update_addon_changelog(addon[ATTR_SLUG]) + for addon in all_addons + ] ) ) self.hass.data[DATA_ADDONS_INFO] = dict( await asyncio.gather( - *[self._update_addon_info(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_info(addon[ATTR_SLUG]) for addon in all_addons] ) ) From 1189b2ad70bddae4714a9df3fd41dcc3e9c50f78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 15:15:36 -1000 Subject: [PATCH 0432/1165] Small speed up to _collection_changed (#90621) attrgetter builds a fast method which happens in native code https://github.com/python/cpython/blob/4664a7cf689946f0c9854cadee7c6aa9c276a8cf/Modules/_operator.c#L1413 --- homeassistant/helpers/collection.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 437cd41871..9da6f84207 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass from itertools import groupby import logging +from operator import attrgetter from typing import Any, cast import voluptuous as vol @@ -410,9 +411,8 @@ def sync_entity_lifecycle( # Create a new bucket every time we have a different change type # to ensure operations happen in order. We only group # the same change type. - for _, grouped in groupby( - change_sets, lambda change_set: change_set.change_type - ): + groupby_key = attrgetter("change_type") + for _, grouped in groupby(change_sets, groupby_key): new_entities = [ entity for entity in await asyncio.gather( From 75694307e2ac768d021e613cea372a92d5d57b83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 09:15:17 -1000 Subject: [PATCH 0433/1165] Bump zeroconf to 0.51.0 (#90622) * Bump zeroconf to 0.50.0 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.47.4...0.50.0 * bump to 51 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index b7a643bb46..36c2fcc127 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.47.4"] + "requirements": ["zeroconf==0.51.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cde6be3c20..da8e3ca387 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.47.4 +zeroconf==0.51.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index ee45c2b387..541b841acc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e9d6cc001..7bf409b71f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1929,7 +1929,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From bacd77a03addbe094deea7bd296395c5a5216ef6 Mon Sep 17 00:00:00 2001 From: nono Date: Sat, 1 Apr 2023 17:45:24 +0200 Subject: [PATCH 0434/1165] Fix Rest switch init was not retrying if unreachable at setup (#90627) * Fix Rest switch init was not retrying if unreachable at setup * pass error log to platformnotready prevents spamming the same message in logs. --- homeassistant/components/rest/switch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index cda35d1f91..9e016db037 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,8 +98,8 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("No route to resource/endpoint: %s", resource) + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc class RestSwitch(TemplateEntity, SwitchEntity): From c006b3b1df81203b9aa822dc6292a2df3d1378ce Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Apr 2023 21:17:53 +0200 Subject: [PATCH 0435/1165] Fix mqtt device_tracker is not reloading yaml (#90639) --- homeassistant/components/mqtt/const.py | 1 + tests/components/mqtt/test_device_tracker.py | 21 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index bb6b8ed497..41fd353359 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -113,6 +113,7 @@ RELOADABLE_PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DEVICE_TRACKER, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index a8c45f8cd7..a0ac73953b 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -10,10 +10,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .test_common import help_test_setting_blocked_attribute_via_mqtt_json_message +from .test_common import ( + help_test_reloadable, + help_test_setting_blocked_attribute_via_mqtt_json_message, +) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator, WebSocketGenerator +from tests.typing import ( + MqttMockHAClientGenerator, + MqttMockPahoClient, + WebSocketGenerator, +) DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -603,3 +610,13 @@ async def test_setup_with_modern_schema( dev_id = "jan" entity_id = f"{device_tracker.DOMAIN}.{dev_id}" assert hass.states.get(entity_id) is not None + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = device_tracker.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) From 2a28d40dc88627195b9d926d679f77a90699ee67 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 1 Apr 2023 21:21:51 +0200 Subject: [PATCH 0436/1165] Update frontend to 20230401.0 (#90646) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 114760923e..6468bd6daa 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==20230331.0"] + "requirements": ["home-assistant-frontend==20230401.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index da8e3ca387..4763b3ab94 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 541b841acc..bfb35d4658 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bf409b71f..a0cc8fe7df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From aa6cf3d2083babbef62e23106effa5063e1b7978 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Apr 2023 15:23:53 -0400 Subject: [PATCH 0437/1165] Bumped version to 2023.4.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 38c243997a..039f5bcc7b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index f000a293db..bce981eb6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b3" +version = "2023.4.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5fc103947f73e5f5dd733f2722704c61051caa9d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 03:39:46 +0200 Subject: [PATCH 0438/1165] Add entity name translations to Brother (#90634) * Add entity name translations * Fix sensor name * Update tests * Suggested change --- homeassistant/components/brother/sensor.py | 68 +++++------ homeassistant/components/brother/strings.json | 106 ++++++++++++++++++ tests/components/brother/test_sensor.py | 42 +++---- 3 files changed, 161 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 274576f0f3..191bfff249 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -53,14 +53,14 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="status", icon="mdi:printer", - name="Status", + translation_key="status", entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.status, ), BrotherSensorEntityDescription( key="page_counter", icon="mdi:file-document-outline", - name="Page counter", + translation_key="page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -69,7 +69,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="bw_counter", icon="mdi:file-document-outline", - name="B/W counter", + translation_key="bw_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -78,7 +78,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="color_counter", icon="mdi:file-document-outline", - name="Color counter", + translation_key="color_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -87,7 +87,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="duplex_unit_pages_counter", icon="mdi:file-document-outline", - name="Duplex unit pages counter", + translation_key="duplex_unit_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -96,7 +96,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_remaining_life", icon="mdi:chart-donut", - name="Drum remaining life", + translation_key="drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -105,7 +105,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_remaining_pages", icon="mdi:chart-donut", - name="Drum remaining pages", + translation_key="drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -114,7 +114,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_counter", icon="mdi:chart-donut", - name="Drum counter", + translation_key="drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -123,7 +123,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_remaining_life", icon="mdi:chart-donut", - name="Black drum remaining life", + translation_key="black_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -132,7 +132,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_remaining_pages", icon="mdi:chart-donut", - name="Black drum remaining pages", + translation_key="black_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -141,7 +141,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_counter", icon="mdi:chart-donut", - name="Black drum counter", + translation_key="black_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -150,7 +150,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_remaining_life", icon="mdi:chart-donut", - name="Cyan drum remaining life", + translation_key="cyan_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_remaining_pages", icon="mdi:chart-donut", - name="Cyan drum remaining pages", + translation_key="cyan_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -168,7 +168,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_counter", icon="mdi:chart-donut", - name="Cyan drum counter", + translation_key="cyan_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -177,7 +177,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_remaining_life", icon="mdi:chart-donut", - name="Magenta drum remaining life", + translation_key="magenta_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -186,7 +186,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_remaining_pages", icon="mdi:chart-donut", - name="Magenta drum remaining pages", + translation_key="magenta_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -195,7 +195,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_counter", icon="mdi:chart-donut", - name="Magenta drum counter", + translation_key="magenta_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -204,7 +204,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_remaining_life", icon="mdi:chart-donut", - name="Yellow drum remaining life", + translation_key="yellow_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -213,7 +213,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_remaining_pages", icon="mdi:chart-donut", - name="Yellow drum remaining pages", + translation_key="yellow_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -222,7 +222,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_counter", icon="mdi:chart-donut", - name="Yellow drum counter", + translation_key="yellow_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -231,7 +231,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="belt_unit_remaining_life", icon="mdi:current-ac", - name="Belt unit remaining life", + translation_key="belt_unit_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -240,7 +240,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="fuser_remaining_life", icon="mdi:water-outline", - name="Fuser remaining life", + translation_key="fuser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -249,7 +249,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="laser_remaining_life", icon="mdi:spotlight-beam", - name="Laser remaining life", + translation_key="laser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -258,7 +258,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="pf_kit_1_remaining_life", icon="mdi:printer-3d", - name="PF Kit 1 remaining life", + translation_key="pf_kit_1_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -267,7 +267,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="pf_kit_mp_remaining_life", icon="mdi:printer-3d", - name="PF Kit MP remaining life", + translation_key="pf_kit_mp_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -276,7 +276,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Black toner remaining", + translation_key="black_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -285,7 +285,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan toner remaining", + translation_key="cyan_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -294,7 +294,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta toner remaining", + translation_key="magenta_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -303,7 +303,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow toner remaining", + translation_key="yellow_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -312,7 +312,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Black ink remaining", + translation_key="black_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -321,7 +321,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan ink remaining", + translation_key="cyan_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -330,7 +330,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta ink remaining", + translation_key="magenta_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -339,7 +339,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow ink remaining", + translation_key="yellow_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -347,7 +347,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="last_restart", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index 9d7d42abef..3ee3fe7609 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -25,5 +25,111 @@ "unsupported_model": "This printer model is not supported.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "status": { + "name": "Status" + }, + "page_counter": { + "name": "Page counter" + }, + "bw_pages": { + "name": "B/W pages" + }, + "color_pages": { + "name": "Color pages" + }, + "duplex_unit_page_counter": { + "name": "Duplex unit page counter" + }, + "drum_remaining_life": { + "name": "Drum remaining life" + }, + "drum_remaining_pages": { + "name": "Drum remaining pages" + }, + "drum_page_counter": { + "name": "Drum page counter" + }, + "black_drum_remaining_life": { + "name": "Black drum remaining life" + }, + "black_drum_remaining_pages": { + "name": "Black drum remaining pages" + }, + "black_drum_page_counter": { + "name": "Black drum page counter" + }, + "cyan_drum_remaining_life": { + "name": "Cyan drum remaining life" + }, + "cyan_drum_remaining_pages": { + "name": "Cyan drum remaining pages" + }, + "cyan_drum_page_counter": { + "name": "Cyan drum page counter" + }, + "magenta_drum_remaining_life": { + "name": "Magenta drum remaining life" + }, + "magenta_drum_remaining_pages": { + "name": "Magenta drum remaining pages" + }, + "magenta_drum_page_counter": { + "name": "Magenta drum page counter" + }, + "yellow_drum_remaining_life": { + "name": "Yellow drum remaining life" + }, + "yellow_drum_remaining_pages": { + "name": "Yellow drum remaining pages" + }, + "yellow_drum_page_counter": { + "name": "Yellow drum page counter" + }, + "belt_unit_remaining_life": { + "name": "Belt unit remaining life" + }, + "fuser_remaining_life": { + "name": "Fuser remaining life" + }, + "laser_remaining_life": { + "name": "Laser remaining life" + }, + "pf_kit_1_remaining_life": { + "name": "PF Kit 1 remaining life" + }, + "pf_kit_mp_remaining_life": { + "name": "PF Kit MP remaining life" + }, + "black_toner_remaining": { + "name": "Black toner remaining" + }, + "cyan_toner_remaining": { + "name": "Cyan toner remaining" + }, + "magenta_toner_remaining": { + "name": "Magenta toner remaining" + }, + "yellow_toner_remaining": { + "name": "Yellow toner remaining" + }, + "black_ink_remaining": { + "name": "Black ink remaining" + }, + "cyan_ink_remaining": { + "name": "Cyan ink remaining" + }, + "magenta_ink_remaining": { + "name": "Magenta ink remaining" + }, + "yellow_ink_remaining": { + "name": "Yellow ink remaining" + }, + "last_restart": { + "name": "Last restart" + } + } } } diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 6769d21940..e05fce9df3 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -43,7 +43,7 @@ async def test_sensors(hass: HomeAssistant) -> None: SENSOR_DOMAIN, DOMAIN, "0123456789_uptime", - suggested_object_id="hl_l2340dw_uptime", + suggested_object_id="hl_l2340dw_last_restart", disabled_by=None, ) test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC) @@ -132,14 +132,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "986" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_drum_page_counter") assert entry assert entry.unique_id == "0123456789_drum_counter" @@ -165,14 +165,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_black_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_black_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_black_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_black_drum_page_counter") assert entry assert entry.unique_id == "0123456789_black_drum_counter" @@ -198,14 +198,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_cyan_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_cyan_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_cyan_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_page_counter") assert entry assert entry.unique_id == "0123456789_cyan_drum_counter" @@ -231,14 +231,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_magenta_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_magenta_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_magenta_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_page_counter") assert entry assert entry.unique_id == "0123456789_magenta_drum_counter" @@ -264,14 +264,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_yellow_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_yellow_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_yellow_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_page_counter") assert entry assert entry.unique_id == "0123456789_yellow_drum_counter" @@ -319,40 +319,40 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_page_counter" - state = hass.states.get("sensor.hl_l2340dw_duplex_unit_pages_counter") + state = hass.states.get("sensor.hl_l2340dw_duplex_unit_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "538" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_pages_counter") + entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_page_counter") assert entry assert entry.unique_id == "0123456789_duplex_unit_pages_counter" - state = hass.states.get("sensor.hl_l2340dw_b_w_counter") + state = hass.states.get("sensor.hl_l2340dw_b_w_pages") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "709" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_b_w_counter") + entry = registry.async_get("sensor.hl_l2340dw_b_w_pages") assert entry assert entry.unique_id == "0123456789_bw_counter" - state = hass.states.get("sensor.hl_l2340dw_color_counter") + state = hass.states.get("sensor.hl_l2340dw_color_pages") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "902" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_color_counter") + entry = registry.async_get("sensor.hl_l2340dw_color_pages") assert entry assert entry.unique_id == "0123456789_color_counter" - state = hass.states.get("sensor.hl_l2340dw_uptime") + state = hass.states.get("sensor.hl_l2340dw_last_restart") assert state assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None @@ -360,7 +360,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "2019-09-24T12:14:56+00:00" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.hl_l2340dw_uptime") + entry = registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" @@ -370,10 +370,10 @@ async def test_disabled_by_default_sensors(hass: HomeAssistant) -> None: await init_integration(hass) registry = er.async_get(hass) - state = hass.states.get("sensor.hl_l2340dw_uptime") + state = hass.states.get("sensor.hl_l2340dw_last_restart") assert state is None - entry = registry.async_get("sensor.hl_l2340dw_uptime") + entry = registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" assert entry.disabled From 84292d4797367e1a153c90874386eefed67f035a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 15:40:14 -1000 Subject: [PATCH 0439/1165] Cleanup some duplicate code in recorder statistics (#90549) * Cleanup some duplicate code in recorder statistics * more cleanup * reduce * reduce --- .../components/recorder/statistics.py | 106 +++++++++--------- tests/components/recorder/test_statistics.py | 26 ++--- 2 files changed, 60 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 0122ba4464..70e82fad5d 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1034,18 +1034,19 @@ def _reduce_statistics_per_month( def _generate_statistics_during_period_stmt( - columns: Select, start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, table: type[StatisticsBase], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Prepare a database query for statistics during a given period. This prepares a lambda_stmt query, so we don't insert the parameters yet. """ start_time_ts = start_time.timestamp() - stmt = lambda_stmt(lambda: columns.filter(table.start_ts >= start_time_ts)) + stmt = _generate_select_columns_for_types_stmt(table, types) + stmt += lambda q: q.filter(table.start_ts >= start_time_ts) if end_time is not None: end_time_ts = end_time.timestamp() stmt += lambda q: q.filter(table.start_ts < end_time_ts) @@ -1491,6 +1492,33 @@ def statistic_during_period( return {key: convert(value) if convert else value for key, value in result.items()} +_type_column_mapping = { + "last_reset": "last_reset_ts", + "max": "max", + "mean": "mean", + "min": "min", + "state": "state", + "sum": "sum", +} + + +def _generate_select_columns_for_types_stmt( + table: type[StatisticsBase], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> StatementLambdaElement: + columns = select(table.metadata_id, table.start_ts) + track_on: list[str | None] = [ + table.__tablename__, # type: ignore[attr-defined] + ] + for key, column in _type_column_mapping.items(): + if key in types: + columns = columns.add_columns(getattr(table, column)) + track_on.append(column) + else: + track_on.append(None) + return lambda_stmt(lambda: columns, track_on=track_on) + + def _statistics_during_period_with_session( hass: HomeAssistant, session: Session, @@ -1525,21 +1553,8 @@ def _statistics_during_period_with_session( table: type[Statistics | StatisticsShortTerm] = ( Statistics if period != "5minute" else StatisticsShortTerm ) - columns = select(table.metadata_id, table.start_ts) # type: ignore[call-overload] - if "last_reset" in types: - columns = columns.add_columns(table.last_reset_ts) - if "max" in types: - columns = columns.add_columns(table.max) - if "mean" in types: - columns = columns.add_columns(table.mean) - if "min" in types: - columns = columns.add_columns(table.min) - if "state" in types: - columns = columns.add_columns(table.state) - if "sum" in types: - columns = columns.add_columns(table.sum) stmt = _generate_statistics_during_period_stmt( - columns, start_time, end_time, metadata_ids, table + start_time, end_time, metadata_ids, table, types ) stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) @@ -1771,34 +1786,34 @@ def get_latest_short_term_statistics( def _generate_statistics_at_time_stmt( - columns: Select, table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Create the statement for finding the statistics for a given time.""" - return lambda_stmt( - lambda: columns.join( - ( - most_recent_statistic_ids := ( - select( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(table.start_ts).label("max_start_ts"), - table.metadata_id.label("max_metadata_id"), - ) - .filter(table.start_ts < start_time_ts) - .filter(table.metadata_id.in_(metadata_ids)) - .group_by(table.metadata_id) - .subquery() + stmt = _generate_select_columns_for_types_stmt(table, types) + stmt += lambda q: q.join( + ( + most_recent_statistic_ids := ( + select( + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(table.start_ts).label("max_start_ts"), + table.metadata_id.label("max_metadata_id"), ) - ), - and_( - table.start_ts == most_recent_statistic_ids.c.max_start_ts, - table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, - ), - ) + .filter(table.start_ts < start_time_ts) + .filter(table.metadata_id.in_(metadata_ids)) + .group_by(table.metadata_id) + .subquery() + ) + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), ) + return stmt def _statistics_at_time( @@ -1809,23 +1824,8 @@ def _statistics_at_time( types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" - columns = select(table.metadata_id, table.start_ts) - if "last_reset" in types: - columns = columns.add_columns(table.last_reset_ts) - if "max" in types: - columns = columns.add_columns(table.max) - if "mean" in types: - columns = columns.add_columns(table.mean) - if "min" in types: - columns = columns.add_columns(table.min) - if "state" in types: - columns = columns.add_columns(table.state) - if "sum" in types: - columns = columns.add_columns(table.sum) start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt( - columns, table, metadata_ids, start_time_ts - ) + stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ff42979431..25890fe475 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1244,28 +1244,21 @@ def test_monthly_statistics( def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" - columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm + dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, set() ) cache_key_1 = stmt._generate_cache_key() stmt2 = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm + dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, set() ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - columns2 = select( - StatisticsShortTerm.metadata_id, - StatisticsShortTerm.start_ts, - StatisticsShortTerm.sum, - StatisticsShortTerm.mean, - ) stmt3 = _generate_statistics_during_period_stmt( - columns2, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, + {"sum", "mean"}, ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 @@ -1321,18 +1314,13 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> N def test_cache_key_for_generate_statistics_at_time_stmt() -> None: """Test cache key for _generate_statistics_at_time_stmt.""" - columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) - stmt = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + stmt = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) cache_key_1 = stmt._generate_cache_key() - stmt2 = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + stmt2 = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - columns2 = select( - StatisticsShortTerm.metadata_id, - StatisticsShortTerm.start_ts, - StatisticsShortTerm.sum, - StatisticsShortTerm.mean, + stmt3 = _generate_statistics_at_time_stmt( + StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} ) - stmt3 = _generate_statistics_at_time_stmt(columns2, StatisticsShortTerm, {0}, 0.0) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 From 4a4d3201f5dbcbd1911b2929ef140e40ec62bc2f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Apr 2023 23:34:52 -0400 Subject: [PATCH 0440/1165] Fix voice assistant error variable (#90658) --- homeassistant/components/voice_assistant/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index ef13d54e6a..b41ab8ef9f 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -197,7 +197,7 @@ class PipelineRun: raise SpeechToTextError( code="stt-provider-unsupported-metadata", message=( - f"Provider {engine} does not support input speech " + f"Provider {stt_provider.name} does not support input speech " "to text metadata" ), ) From 17270979e6d3627aaf2edbecdea28ad827006c3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 08:09:44 -1000 Subject: [PATCH 0441/1165] Bump zeroconf to 0.52.0 (#90660) * Bump zeroconf to 0.52.0 Switch to using the new ip_addresses_by_version which avoids all the ip address conversions * updates --- homeassistant/components/zeroconf/__init__.py | 37 +++++-------------- .../components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 13 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index badc124271..a3a055b29c 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -564,14 +564,19 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: if isinstance(value, bytes): properties[key] = value.decode("utf-8") - if not (addresses := service.addresses or service.parsed_addresses()): + if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)): return None - if (host := _first_non_link_local_address(addresses)) is None: + host: str | None = None + for ip_addr in ip_addresses: + if not ip_addr.is_link_local and not ip_addr.is_unspecified: + host = str(ip_addr) + break + if not host: return None return ZeroconfServiceInfo( - host=str(host), - addresses=service.parsed_addresses(), + host=host, + addresses=[str(ip_addr) for ip_addr in ip_addresses], port=service.port, hostname=service.server, type=service.type, @@ -580,30 +585,6 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: ) -def _first_non_link_local_address( - addresses: list[bytes] | list[str], -) -> str | None: - """Return the first ipv6 or non-link local ipv4 address, preferring IPv4.""" - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 4 - ): - return str(ip_addr) - # If we didn't find a good IPv4 address, check for IPv6 addresses. - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 6 - ): - return str(ip_addr) - return None - - def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 36c2fcc127..09fc07684c 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.51.0"] + "requirements": ["zeroconf==0.52.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8c49308503..704ebd9965 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.51.0 +zeroconf==0.52.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index fedca63529..885ccd97b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.51.0 +zeroconf==0.52.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d637999fb3..7f28d06d55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.51.0 +zeroconf==0.52.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From b52fab0f6d40296f3e5f38565cbebe451ec75a3b Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 2 Apr 2023 11:22:16 -0700 Subject: [PATCH 0442/1165] Rename Android TV to Android Debug Bridge (#90657) * Rename Android TV to Android debug bridge * More renaming --- .../components/androidtv/__init__.py | 10 +- .../components/androidtv/config_flow.py | 11 ++- homeassistant/components/androidtv/const.py | 2 +- .../components/androidtv/manifest.json | 2 +- .../components/androidtv/media_player.py | 18 ++-- .../components/androidtv/services.yaml | 12 +-- .../components/androidtv/strings.json | 4 +- homeassistant/generated/integrations.json | 2 +- .../components/androidtv/test_config_flow.py | 6 +- .../components/androidtv/test_media_player.py | 92 +++++++++---------- 10 files changed, 80 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index d10b1161da..4a1ad55e0b 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV/Fire TV devices.""" +"""Support for functionality to interact with Android/Fire TV devices.""" from __future__ import annotations from collections.abc import Mapping @@ -135,11 +135,11 @@ async def async_connect_androidtv( if not aftv.available: # Determine the name that will be used for the device in the log if config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: - device_name = "Android TV device" + device_name = "Android device" elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: device_name = "Fire TV device" else: - device_name = "Android TV / Fire TV device" + device_name = "Android / Fire TV device" error_message = f"Could not connect to {device_name} at {address} {adb_log}" return None, error_message @@ -148,7 +148,7 @@ async def async_connect_androidtv( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Android TV platform.""" + """Set up Android Debug Bridge platform.""" state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES) if CONF_ADB_SERVER_IP not in entry.data: @@ -167,7 +167,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(error_message) async def async_close_connection(event): - """Close Android TV connection on HA Stop.""" + """Close Android Debug Bridge connection on HA Stop.""" await aftv.adb_close() entry.async_on_unload( diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index bac5a9aec6..7e2b1e85f3 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the Android TV integration.""" +"""Config flow to configure the Android Debug Bridge integration.""" from __future__ import annotations import logging @@ -114,13 +114,14 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): async def _async_check_connection( self, user_input: dict[str, Any] ) -> tuple[str | None, str | None]: - """Attempt to connect the Android TV.""" + """Attempt to connect the Android device.""" try: aftv, error_message = await async_connect_androidtv(self.hass, user_input) except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Unknown error connecting with Android TV at %s", user_input[CONF_HOST] + "Unknown error connecting with Android device at %s", + user_input[CONF_HOST], ) return RESULT_UNKNOWN, None @@ -130,7 +131,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): dev_prop = aftv.device_properties _LOGGER.info( - "Android TV at %s: %s = %r, %s = %r", + "Android device at %s: %s = %r, %s = %r", user_input[CONF_HOST], PROP_ETHMAC, dev_prop.get(PROP_ETHMAC), @@ -184,7 +185,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlowWithConfigEntry): - """Handle an option flow for Android TV.""" + """Handle an option flow for Android Debug Bridge.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" diff --git a/homeassistant/components/androidtv/const.py b/homeassistant/components/androidtv/const.py index 7f1e128851..1793642168 100644 --- a/homeassistant/components/androidtv/const.py +++ b/homeassistant/components/androidtv/const.py @@ -1,4 +1,4 @@ -"""Android TV component constants.""" +"""Android Debug Bridge component constants.""" DOMAIN = "androidtv" ANDROID_DEV = DOMAIN diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 2de47c65ad..f782db7987 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -1,6 +1,6 @@ { "domain": "androidtv", - "name": "Android TV", + "name": "Android Debug Bridge", "codeowners": ["@JeffLIrion", "@ollo69"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/androidtv", diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index fb01ffce77..563b8f07b2 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV / Fire TV devices.""" +"""Support for functionality to interact with Android / Fire TV devices.""" from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine @@ -87,7 +87,7 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Android TV entity.""" + """Set up the Android Debug Bridge entity.""" aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] device_class = aftv.DEVICE_CLASS device_type = ( @@ -201,7 +201,7 @@ def adb_decorator( class ADBDevice(MediaPlayerEntity): - """Representation of an Android TV or Fire TV device.""" + """Representation of an Android or Fire TV device.""" _attr_device_class = MediaPlayerDeviceClass.TV @@ -214,7 +214,7 @@ class ADBDevice(MediaPlayerEntity): entry_id, entry_data, ): - """Initialize the Android TV / Fire TV device.""" + """Initialize the Android / Fire TV device.""" self.aftv = aftv self._attr_name = name self._attr_unique_id = unique_id @@ -384,7 +384,7 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def adb_command(self, command): - """Send an ADB command to an Android TV / Fire TV device.""" + """Send an ADB command to an Android / Fire TV device.""" if key := KEYS.get(command): await self.aftv.adb_shell(f"input keyevent {key}") return @@ -422,13 +422,13 @@ class ADBDevice(MediaPlayerEntity): persistent_notification.async_create( self.hass, msg, - title="Android TV", + title="Android Debug Bridge", ) _LOGGER.info("%s", msg) @adb_decorator() async def service_download(self, device_path, local_path): - """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + """Download a file from your Android / Fire TV device to your Home Assistant instance.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -437,7 +437,7 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def service_upload(self, device_path, local_path): - """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + """Upload a file from your Home Assistant instance to an Android / Fire TV device.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -446,7 +446,7 @@ class ADBDevice(MediaPlayerEntity): class AndroidTVDevice(ADBDevice): - """Representation of an Android TV device.""" + """Representation of an Android device.""" _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml index fef06266e5..4482f50f3e 100644 --- a/homeassistant/components/androidtv/services.yaml +++ b/homeassistant/components/androidtv/services.yaml @@ -1,8 +1,8 @@ -# Describes the format for available Android TV and Fire TV services +# Describes the format for available Android and Fire TV services adb_command: name: ADB command - description: Send an ADB command to an Android TV / Fire TV device. + description: Send an ADB command to an Android / Fire TV device. target: entity: integration: androidtv @@ -17,7 +17,7 @@ adb_command: text: download: name: Download - description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. + description: Download a file from your Android / Fire TV device to your Home Assistant instance. target: entity: integration: androidtv @@ -25,7 +25,7 @@ download: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: @@ -39,7 +39,7 @@ download: text: upload: name: Upload - description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device. + description: Upload a file from your Home Assistant instance to an Android / Fire TV device. target: entity: integration: androidtv @@ -47,7 +47,7 @@ upload: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json index 7a46228bd4..e7d06a9f62 100644 --- a/homeassistant/components/androidtv/strings.json +++ b/homeassistant/components/androidtv/strings.json @@ -38,7 +38,7 @@ } }, "apps": { - "title": "Configure Android TV Apps", + "title": "Configure Android Apps", "description": "Configure application id {app_id}", "data": { "app_name": "Application Name", @@ -47,7 +47,7 @@ } }, "rules": { - "title": "Configure Android TV state detection rules", + "title": "Configure Android state detection rules", "description": "Configure detection rule for application id {rule_id}", "data": { "rule_id": "Application ID", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9e5155f0cb..311f8414f9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -241,7 +241,7 @@ "iot_class": "local_polling" }, "androidtv": { - "name": "Android TV", + "name": "Android Debug Bridge", "integration_type": "device", "config_flow": true, "iot_class": "local_polling" diff --git a/tests/components/androidtv/test_config_flow.py b/tests/components/androidtv/test_config_flow.py index ed118bc827..ad7d3be290 100644 --- a/tests/components/androidtv/test_config_flow.py +++ b/tests/components/androidtv/test_config_flow.py @@ -48,14 +48,14 @@ INVALID_MAC = "ff:ff:ff:ff:ff:ff" HOST = "127.0.0.1" VALID_DETECT_RULE = [{"paused": {"media_session_state": 3}}] -# Android TV device with Python ADB implementation +# Android device with Python ADB implementation CONFIG_PYTHON_ADB = { CONF_HOST: HOST, CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: DEVICE_ANDROIDTV, } -# Android TV device with ADB server +# Android device with ADB server CONFIG_ADB_SERVER = { CONF_HOST: HOST, CONF_PORT: DEFAULT_PORT, @@ -70,7 +70,7 @@ CONNECT_METHOD = ( class MockConfigDevice: - """Mock class to emulate Android TV device.""" + """Mock class to emulate Android device.""" def __init__(self, eth_mac=ETH_MAC, wifi_mac=None): """Initialize a fake device to test config flow.""" diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index 3ecbd5b05f..59c7ce751a 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -95,8 +95,8 @@ MSG_RECONNECT = { SHELL_RESPONSE_OFF = "" SHELL_RESPONSE_STANDBY = "1" -# Android TV device with Python ADB implementation -CONFIG_ANDROIDTV_PYTHON_ADB = { +# Android device with Python ADB implementation +CONFIG_ANDROID_PYTHON_ADB = { ADB_PATCH_KEY: patchers.KEY_PYTHON, TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {HOST}", DOMAIN: { @@ -106,28 +106,28 @@ CONFIG_ANDROIDTV_PYTHON_ADB = { }, } -# Android TV device with Python ADB implementation imported from YAML -CONFIG_ANDROIDTV_PYTHON_ADB_YAML = { +# Android device with Python ADB implementation imported from YAML +CONFIG_ANDROID_PYTHON_ADB_YAML = { ADB_PATCH_KEY: patchers.KEY_PYTHON, TEST_ENTITY_NAME: "ADB yaml import", DOMAIN: { CONF_NAME: "ADB yaml import", - **CONFIG_ANDROIDTV_PYTHON_ADB[DOMAIN], + **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], }, } -# Android TV device with Python ADB implementation with custom adbkey -CONFIG_ANDROIDTV_PYTHON_ADB_KEY = { +# Android device with Python ADB implementation with custom adbkey +CONFIG_ANDROID_PYTHON_ADB_KEY = { ADB_PATCH_KEY: patchers.KEY_PYTHON, - TEST_ENTITY_NAME: CONFIG_ANDROIDTV_PYTHON_ADB[TEST_ENTITY_NAME], + TEST_ENTITY_NAME: CONFIG_ANDROID_PYTHON_ADB[TEST_ENTITY_NAME], DOMAIN: { - **CONFIG_ANDROIDTV_PYTHON_ADB[DOMAIN], + **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], CONF_ADBKEY: "user_provided_adbkey", }, } -# Android TV device with ADB server -CONFIG_ANDROIDTV_ADB_SERVER = { +# Android device with ADB server +CONFIG_ANDROID_ADB_SERVER = { ADB_PATCH_KEY: patchers.KEY_SERVER, TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {HOST}", DOMAIN: { @@ -163,7 +163,7 @@ CONFIG_FIRETV_ADB_SERVER = { }, } -CONFIG_ANDROIDTV_DEFAULT = CONFIG_ANDROIDTV_PYTHON_ADB +CONFIG_ANDROID_DEFAULT = CONFIG_ANDROID_PYTHON_ADB CONFIG_FIRETV_DEFAULT = CONFIG_FIRETV_PYTHON_ADB @@ -213,10 +213,10 @@ def _setup(config): @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_PYTHON_ADB, - CONFIG_ANDROIDTV_PYTHON_ADB_YAML, + CONFIG_ANDROID_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB_YAML, CONFIG_FIRETV_PYTHON_ADB, - CONFIG_ANDROIDTV_ADB_SERVER, + CONFIG_ANDROID_ADB_SERVER, CONFIG_FIRETV_ADB_SERVER, ], ) @@ -275,9 +275,9 @@ async def test_reconnect( @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB, CONFIG_FIRETV_PYTHON_ADB, - CONFIG_ANDROIDTV_ADB_SERVER, + CONFIG_ANDROID_ADB_SERVER, CONFIG_FIRETV_ADB_SERVER, ], ) @@ -313,7 +313,7 @@ async def test_adb_shell_returns_none( async def test_setup_with_adbkey(hass: HomeAssistant) -> None: """Test that setup succeeds when using an ADB key.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_PYTHON_ADB_KEY) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_PYTHON_ADB_KEY) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -331,12 +331,12 @@ async def test_setup_with_adbkey(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_DEFAULT, + CONFIG_ANDROID_DEFAULT, CONFIG_FIRETV_DEFAULT, ], ) async def test_sources(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Test that sources (i.e., apps) are handled correctly for Android TV and Fire TV devices.""" + """Test that sources (i.e., apps) are handled correctly for Android and Fire TV devices.""" conf_apps = { "com.app.test1": "TEST 1", "com.app.test3": None, @@ -397,7 +397,7 @@ async def test_sources(hass: HomeAssistant, config: dict[str, Any]) -> None: @pytest.mark.parametrize( ("config", "expected_sources"), [ - (CONFIG_ANDROIDTV_DEFAULT, ["TEST 1"]), + (CONFIG_ANDROID_DEFAULT, ["TEST 1"]), (CONFIG_FIRETV_DEFAULT, ["TEST 1"]), ], ) @@ -503,7 +503,7 @@ async def test_select_source_androidtv( "com.app.test3": None, } await _test_select_source( - hass, CONFIG_ANDROIDTV_DEFAULT, conf_apps, source, expected_arg, method_patch + hass, CONFIG_ANDROID_DEFAULT, conf_apps, source, expected_arg, method_patch ) @@ -517,7 +517,7 @@ async def test_androidtv_select_source_overridden_app_name(hass: HomeAssistant) assert "com.youtube.test" not in ANDROIDTV_APPS await _test_select_source( hass, - CONFIG_ANDROIDTV_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB, conf_apps, "YouTube", "com.youtube.test", @@ -554,9 +554,9 @@ async def test_select_source_firetv( @pytest.mark.parametrize( ("config", "connect"), [ - (CONFIG_ANDROIDTV_DEFAULT, False), + (CONFIG_ANDROID_DEFAULT, False), (CONFIG_FIRETV_DEFAULT, False), - (CONFIG_ANDROIDTV_DEFAULT, True), + (CONFIG_ANDROID_DEFAULT, True), (CONFIG_FIRETV_DEFAULT, True), ], ) @@ -581,7 +581,7 @@ async def test_setup_fail( async def test_adb_command(hass: HomeAssistant) -> None: """Test sending a command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "test command" response = "test response" @@ -610,7 +610,7 @@ async def test_adb_command(hass: HomeAssistant) -> None: async def test_adb_command_unicode_decode_error(hass: HomeAssistant) -> None: """Test sending a command via the `androidtv.adb_command` service that raises a UnicodeDecodeError exception.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "test command" response = b"test response" @@ -639,7 +639,7 @@ async def test_adb_command_unicode_decode_error(hass: HomeAssistant) -> None: async def test_adb_command_key(hass: HomeAssistant) -> None: """Test sending a key command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "HOME" response = None @@ -668,7 +668,7 @@ async def test_adb_command_key(hass: HomeAssistant) -> None: async def test_adb_command_get_properties(hass: HomeAssistant) -> None: """Test sending the "GET_PROPERTIES" command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "GET_PROPERTIES" response = {"test key": "test value"} @@ -698,7 +698,7 @@ async def test_adb_command_get_properties(hass: HomeAssistant) -> None: async def test_learn_sendevent(hass: HomeAssistant) -> None: """Test the `androidtv.learn_sendevent` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) response = "sendevent 1 2 3 4" @@ -727,7 +727,7 @@ async def test_learn_sendevent(hass: HomeAssistant) -> None: async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: """Test that the state does not get updated when a `LockNotAcquiredException` is raised.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -760,7 +760,7 @@ async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: async def test_download(hass: HomeAssistant) -> None: """Test the `androidtv.download` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) device_path = "device/path" local_path = "local/path" @@ -806,7 +806,7 @@ async def test_download(hass: HomeAssistant) -> None: async def test_upload(hass: HomeAssistant) -> None: """Test the `androidtv.upload` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) device_path = "device/path" local_path = "local/path" @@ -851,8 +851,8 @@ async def test_upload(hass: HomeAssistant) -> None: async def test_androidtv_volume_set(hass: HomeAssistant) -> None: - """Test setting the volume for an Android TV device.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + """Test setting the volume for an Android device.""" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -881,7 +881,7 @@ async def test_get_image_http( This is based on `test_get_image_http` in tests/components/media_player/test_init.py. """ - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -894,7 +894,7 @@ async def test_get_image_http( await async_update_entity(hass, entity_id) media_player_name = "media_player." + slugify( - CONFIG_ANDROIDTV_DEFAULT[TEST_ENTITY_NAME] + CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] ) state = hass.states.get(media_player_name) assert "entity_picture_local" not in state.attributes @@ -923,7 +923,7 @@ async def test_get_image_http( async def test_get_image_disabled(hass: HomeAssistant) -> None: """Test that the screencap option can disable entity_picture.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, options={CONF_SCREENCAP: False} @@ -939,7 +939,7 @@ async def test_get_image_disabled(hass: HomeAssistant) -> None: await async_update_entity(hass, entity_id) media_player_name = "media_player." + slugify( - CONFIG_ANDROIDTV_DEFAULT[TEST_ENTITY_NAME] + CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] ) state = hass.states.get(media_player_name) assert "entity_picture_local" not in state.attributes @@ -954,7 +954,7 @@ async def _test_service( additional_service_data=None, return_value=None, ): - """Test generic Android TV media player entity service.""" + """Test generic Android media player entity service.""" service_data = {ATTR_ENTITY_ID: entity_id} if additional_service_data: service_data.update(additional_service_data) @@ -977,8 +977,8 @@ async def _test_service( async def test_services_androidtv(hass: HomeAssistant) -> None: - """Test media player services for an Android TV device.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + """Test media player services for an Android device.""" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key]: @@ -1042,7 +1042,7 @@ async def test_services_firetv(hass: HomeAssistant) -> None: async def test_volume_mute(hass: HomeAssistant) -> None: """Test the volume mute service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key]: @@ -1085,7 +1085,7 @@ async def test_volume_mute(hass: HomeAssistant) -> None: async def test_connection_closed_on_ha_stop(hass: HomeAssistant) -> None: """Test that the ADB socket connection is closed when HA stops.""" - patch_key, _, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, _, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -1105,7 +1105,7 @@ async def test_exception(hass: HomeAssistant) -> None: HA will attempt to reconnect on the next update. """ - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -1135,7 +1135,7 @@ async def test_exception(hass: HomeAssistant) -> None: async def test_options_reload(hass: HomeAssistant) -> None: """Test changing an option that will cause integration reload.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( From d32fb7c22f645b0abca92e0d5dbda427828ac8fc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 20:24:40 +0200 Subject: [PATCH 0443/1165] Add entity name translations to Airly (#90656) Add entity name translations --- homeassistant/components/airly/sensor.py | 22 ++++++------ homeassistant/components/airly/strings.json | 37 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 754471c9d8..53e15c651a 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -68,7 +68,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_CAQI, icon="mdi:air-filter", - name=ATTR_API_CAQI, + translation_key="caqi", native_unit_of_measurement="CAQI", suggested_display_precision=0, attrs=lambda data: { @@ -80,7 +80,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM1, device_class=SensorDeviceClass.PM1, - name="PM1.0", + translation_key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -88,7 +88,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM25, device_class=SensorDeviceClass.PM25, - name="PM2.5", + translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -100,7 +100,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM10, device_class=SensorDeviceClass.PM10, - name=ATTR_API_PM10, + translation_key="pm10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -112,7 +112,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, - name=ATTR_API_HUMIDITY.capitalize(), + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -120,7 +120,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PRESSURE, device_class=SensorDeviceClass.PRESSURE, - name=ATTR_API_PRESSURE.capitalize(), + translation_key="pressure", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -128,14 +128,14 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, - name=ATTR_API_TEMPERATURE.capitalize(), + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), AirlySensorEntityDescription( key=ATTR_API_CO, - name="Carbon monoxide", + translation_key="co", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -147,7 +147,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_NO2, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, - name="Nitrogen dioxide", + translation_key="no2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_SO2, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, - name="Sulphur dioxide", + translation_key="so2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -171,7 +171,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_O3, device_class=SensorDeviceClass.OZONE, - name="Ozone", + translation_key="o3", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 4f95f26afc..93fcffa571 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -26,5 +26,42 @@ "requests_remaining": "Remaining allowed requests", "requests_per_day": "Allowed requests per day" } + }, + "entity": { + "sensor": { + "caqi": { + "name": "CAQI" + }, + "pm1": { + "name": "PM1.0" + }, + "pm25": { + "name": "PM2.5" + }, + "pm10": { + "name": "PM10" + }, + "humidity": { + "name": "Humidity" + }, + "pressure": { + "name": "Pressure" + }, + "temperature": { + "name": "Temperature" + }, + "co": { + "name": "Carbon monoxide" + }, + "no2": { + "name": "Nitrogen dioxide" + }, + "so2": { + "name": "Sulphur dioxide" + }, + "o3": { + "name": "Ozone" + } + } } } From fc81b829326281ac53d92025ce754cec651f40a1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 20:25:38 +0200 Subject: [PATCH 0444/1165] Add entity name translations to GIOS (#90655) * Add entity name translations * Update tests --- homeassistant/components/gios/sensor.py | 20 ++++-------- homeassistant/components/gios/strings.json | 27 +++++++++++++++ tests/components/gios/test_sensor.py | 38 +++++++++++----------- 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 7cf4b7e7c6..f078cc074e 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -60,7 +60,6 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_AQI, - name="AQI", value=lambda sensors: sensors.aqi.value if sensors.aqi else None, icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, @@ -69,35 +68,34 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_C6H6, - name="C6H6", value=lambda sensors: sensors.c6h6.value if sensors.c6h6 else None, suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="c6h6", ), GiosSensorEntityDescription( key=ATTR_CO, - name="CO", value=lambda sensors: sensors.co.value if sensors.co else None, suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="co", ), GiosSensorEntityDescription( key=ATTR_NO2, - name="NO2", value=lambda sensors: sensors.no2.value if sensors.no2 else None, suggested_display_precision=0, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="no2", ), GiosSensorEntityDescription( key=ATTR_NO2, subkey="index", - name="NO2 index", value=lambda sensors: sensors.no2.index if sensors.no2 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -106,17 +104,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_O3, - name="O3", value=lambda sensors: sensors.o3.value if sensors.o3 else None, suggested_display_precision=0, device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="o3", ), GiosSensorEntityDescription( key=ATTR_O3, subkey="index", - name="O3 index", value=lambda sensors: sensors.o3.index if sensors.o3 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -125,17 +122,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_PM10, - name="PM10", value=lambda sensors: sensors.pm10.value if sensors.pm10 else None, suggested_display_precision=0, device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm10", ), GiosSensorEntityDescription( key=ATTR_PM10, subkey="index", - name="PM10 index", value=lambda sensors: sensors.pm10.index if sensors.pm10 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -144,17 +140,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_PM25, - name="PM2.5", value=lambda sensors: sensors.pm25.value if sensors.pm25 else None, suggested_display_precision=0, device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm25", ), GiosSensorEntityDescription( key=ATTR_PM25, subkey="index", - name="PM2.5 index", value=lambda sensors: sensors.pm25.index if sensors.pm25 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -163,17 +158,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_SO2, - name="SO2", value=lambda sensors: sensors.so2.value if sensors.so2 else None, suggested_display_precision=0, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="so2", ), GiosSensorEntityDescription( key=ATTR_SO2, subkey="index", - name="SO2 index", value=lambda sensors: sensors.so2.index if sensors.so2 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index 53e7dd78a8..bbbd1c3e6c 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -26,6 +26,7 @@ "entity": { "sensor": { "aqi": { + "name": "AQI", "state": { "very_bad": "Very bad", "bad": "Bad", @@ -35,7 +36,17 @@ "very_good": "Very good" } }, + "c6h6": { + "name": "Benzene" + }, + "co": { + "name": "Carbon monoxide" + }, + "no2": { + "name": "Nitrogen dioxide" + }, "no2_index": { + "name": "Nitrogen dioxide index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -45,7 +56,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "o3": { + "name": "Ozone" + }, "o3_index": { + "name": "Ozone index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -55,7 +70,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "pm10": { + "name": "PM10" + }, "pm10_index": { + "name": "PM10 index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -65,7 +84,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "pm25": { + "name": "PM2.5" + }, "pm25_index": { + "name": "PM2.5 index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -75,7 +98,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "so2": { + "name": "Sulphur dioxide" + }, "so2_index": { + "name": "Sulphur dioxide index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 48f0e23840..2eb74ec121 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -35,7 +35,7 @@ async def test_sensor(hass: HomeAssistant) -> None: await init_integration(hass) registry = er.async_get(hass) - state = hass.states.get("sensor.home_c6h6") + state = hass.states.get("sensor.home_benzene") assert state assert state.state == "0.23789" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -46,11 +46,11 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - entry = registry.async_get("sensor.home_c6h6") + entry = registry.async_get("sensor.home_benzene") assert entry assert entry.unique_id == "123-c6h6" - state = hass.states.get("sensor.home_co") + state = hass.states.get("sensor.home_carbon_monoxide") assert state assert state.state == "251.874" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -61,11 +61,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_co") + entry = registry.async_get("sensor.home_carbon_monoxide") assert entry assert entry.unique_id == "123-co" - state = hass.states.get("sensor.home_no2") + state = hass.states.get("sensor.home_nitrogen_dioxide") assert state assert state.state == "7.13411" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -76,11 +76,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_no2") + entry = registry.async_get("sensor.home_nitrogen_dioxide") assert entry assert entry.unique_id == "123-no2" - state = hass.states.get("sensor.home_no2_index") + state = hass.states.get("sensor.home_nitrogen_dioxide_index") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -94,11 +94,11 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_no2_index") + entry = registry.async_get("sensor.home_nitrogen_dioxide_index") assert entry assert entry.unique_id == "123-no2-index" - state = hass.states.get("sensor.home_o3") + state = hass.states.get("sensor.home_ozone") assert state assert state.state == "95.7768" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -109,11 +109,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_o3") + entry = registry.async_get("sensor.home_ozone") assert entry assert entry.unique_id == "123-o3" - state = hass.states.get("sensor.home_o3_index") + state = hass.states.get("sensor.home_ozone_index") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -127,7 +127,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_o3_index") + entry = registry.async_get("sensor.home_ozone_index") assert entry assert entry.unique_id == "123-o3-index" @@ -197,7 +197,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "123-pm25-index" - state = hass.states.get("sensor.home_so2") + state = hass.states.get("sensor.home_sulphur_dioxide") assert state assert state.state == "4.35478" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -208,11 +208,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_so2") + entry = registry.async_get("sensor.home_sulphur_dioxide") assert entry assert entry.unique_id == "123-so2" - state = hass.states.get("sensor.home_so2_index") + state = hass.states.get("sensor.home_sulphur_dioxide_index") assert state assert state.state == "very_good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -226,7 +226,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_so2_index") + entry = registry.async_get("sensor.home_sulphur_dioxide_index") assert entry assert entry.unique_id == "123-so2-index" @@ -341,11 +341,11 @@ async def test_invalid_indexes(hass: HomeAssistant) -> None: """Test states of the sensor when API returns invalid indexes.""" await init_integration(hass, invalid_indexes=True) - state = hass.states.get("sensor.home_no2_index") + state = hass.states.get("sensor.home_nitrogen_dioxide_index") assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_o3_index") + state = hass.states.get("sensor.home_ozone_index") assert state assert state.state == STATE_UNAVAILABLE @@ -357,7 +357,7 @@ async def test_invalid_indexes(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_so2_index") + state = hass.states.get("sensor.home_sulphur_dioxide_index") assert state assert state.state == STATE_UNAVAILABLE From c5a87addc165f234788f598fb5b0bb99efe24088 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Apr 2023 14:28:52 -0400 Subject: [PATCH 0445/1165] Fix frontend test (#90679) --- tests/components/frontend/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 69643b10ec..dcff80d359 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -141,7 +141,7 @@ async def test_frontend_and_static(mock_http_client, mock_onboarded) -> None: text = await resp.text() # Test we can retrieve frontend.js - frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9]{8}.js)", text) + frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9_]{11}.js)", text) assert frontendjs is not None, text resp = await mock_http_client.get(frontendjs.groups(0)[0]) @@ -546,7 +546,7 @@ async def test_auth_authorize(mock_http_client) -> None: # Test we can retrieve authorize.js authorizejs = re.search( - r"(?P\/frontend_latest\/authorize.[A-Za-z0-9]{8}.js)", text + r"(?P\/frontend_latest\/authorize.[A-Za-z0-9_]{11}.js)", text ) assert authorizejs is not None, text From 368d1c9b54878c20014eeac2aacc575b2bc74333 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 13:32:00 -1000 Subject: [PATCH 0446/1165] Bump zeroconf to 0.53.0 (#90682) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 09fc07684c..551471b41e 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.52.0"] + "requirements": ["zeroconf==0.53.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 704ebd9965..c8cd41b307 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.52.0 +zeroconf==0.53.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 885ccd97b3..362797c37f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.52.0 +zeroconf==0.53.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f28d06d55..1fd34a61a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.52.0 +zeroconf==0.53.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 22fd6138bd9f11e19dd657126f9d239193ecc703 Mon Sep 17 00:00:00 2001 From: Patrick ZAJDA Date: Mon, 3 Apr 2023 02:19:03 +0200 Subject: [PATCH 0447/1165] Add entity name translations for Nest sensors (#90677) Signed-off-by: Patrick ZAJDA --- homeassistant/components/nest/sensor_sdm.py | 4 ++-- homeassistant/components/nest/strings.json | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 187ac0ee8c..8eb607b205 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -79,7 +79,7 @@ class TemperatureSensor(SensorBase): _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - _attr_name = "Temperature" + _attr_translation_key = "temperature" @property def native_value(self) -> float: @@ -96,7 +96,7 @@ class HumiditySensor(SensorBase): _attr_device_class = SensorDeviceClass.HUMIDITY _attr_native_unit_of_measurement = PERCENTAGE - _attr_name = "Humidity" + _attr_translation_key = "humidity" @property def native_value(self) -> int: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index bf68d1988d..c0c7042423 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -98,5 +98,15 @@ "title": "Nest Authentication Credentials must be updated", "description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information." } + }, + "entity": { + "sensor": { + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + } + } } } From 6a6b6cf826939c699dc1de1b3215519941fb231f Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Sun, 2 Apr 2023 20:20:11 -0400 Subject: [PATCH 0448/1165] Bump env_canada to v0.5.30 (#90644) --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index c2c2485d94..8e1f17492f 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env_canada==0.5.29"] + "requirements": ["env_canada==0.5.30"] } diff --git a/requirements_all.txt b/requirements_all.txt index 362797c37f..b4def3e903 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -661,7 +661,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env_canada==0.5.29 +env_canada==0.5.30 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fd34a61a5..1c292cc65b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -517,7 +517,7 @@ energyzero==0.4.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.29 +env_canada==0.5.30 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 From 0198c751b450bf3a71654e2bc63a6f4bb6e6e768 Mon Sep 17 00:00:00 2001 From: mletenay Date: Mon, 3 Apr 2023 02:25:29 +0200 Subject: [PATCH 0449/1165] Update goodwe library to v0.2.30 (#90607) --- homeassistant/components/goodwe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 8dad8454d6..45d02dcd2e 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.29"] + "requirements": ["goodwe==0.2.30"] } diff --git a/requirements_all.txt b/requirements_all.txt index b4def3e903..4b60930b9e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -798,7 +798,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.29 +goodwe==0.2.30 # homeassistant.components.google_mail google-api-python-client==2.71.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c292cc65b..4d983d61f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -614,7 +614,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.29 +goodwe==0.2.30 # homeassistant.components.google_mail google-api-python-client==2.71.0 From 17719663f090263920403017208de3c9b14f3c16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 14:51:25 -1000 Subject: [PATCH 0450/1165] Fix memory churn in state templates (#90685) * Fix memory churn in state templates The LRU for state templates was limited to 512 states. As soon as it was exaused, system performance would tank as each template that iterated all states would have to create and GC any state > 512 * does it scale? * avoid copy on all * comment * preen * cover * cover * comments * comments * comments * preen * preen --- homeassistant/bootstrap.py | 1 + homeassistant/helpers/template.py | 98 +++++++++++++++++++++++++++---- tests/helpers/test_template.py | 40 ++++++++++++- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 445ff35793..d98680c70d 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -239,6 +239,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: # Load the registries and cache the result of platform.uname().processor entity.async_setup(hass) + template.async_setup(hass) await asyncio.gather( area_registry.async_load(hass), device_registry.async_load(hass), diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8e5951488b..fb693d6957 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Collection, Generator, Iterable +from collections.abc import Callable, Collection, Generator, Iterable, MutableMapping from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -41,6 +41,7 @@ from jinja2 import pass_context, pass_environment, pass_eval_context from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace +from lru import LRU # pylint: disable=no-name-in-module import voluptuous as vol from homeassistant.const import ( @@ -49,6 +50,8 @@ from homeassistant.const import ( ATTR_LONGITUDE, ATTR_PERSONS, ATTR_UNIT_OF_MEASUREMENT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfLength, @@ -121,11 +124,77 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( "template_cv", default=None ) +# +# CACHED_TEMPLATE_STATES is a rough estimate of the number of entities +# on a typical system. It is used as the initial size of the LRU cache +# for TemplateState objects. +# +# If the cache is too small we will end up creating and destroying +# TemplateState objects too often which will cause a lot of GC activity +# and slow down the system. For systems with a lot of entities and +# templates, this can reach 100000s of object creations and destructions +# per minute. +# +# Since entity counts may grow over time, we will increase +# the size if the number of entities grows via _async_adjust_lru_sizes +# at the start of the system and every 10 minutes if needed. +# CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 +CACHED_TEMPLATE_LRU: MutableMapping[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) +CACHED_TEMPLATE_NO_COLLECT_LRU: MutableMapping[State, TemplateState] = LRU( + CACHED_TEMPLATE_STATES +) +ENTITY_COUNT_GROWTH_FACTOR = 1.2 + + +def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state without collecting.""" + if template_state := CACHED_TEMPLATE_NO_COLLECT_LRU.get(state): + return template_state + template_state = _create_template_state_no_collect(hass, state) + CACHED_TEMPLATE_NO_COLLECT_LRU[state] = template_state + return template_state + + +def _template_state(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state that collects.""" + if template_state := CACHED_TEMPLATE_LRU.get(state): + return template_state + template_state = TemplateState(hass, state) + CACHED_TEMPLATE_LRU[state] = template_state + return template_state + + +def async_setup(hass: HomeAssistant) -> bool: + """Set up tracking the template LRUs.""" + + @callback + def _async_adjust_lru_sizes(_: Any) -> None: + """Adjust the lru cache sizes.""" + new_size = int( + round(hass.states.async_entity_ids_count() * ENTITY_COUNT_GROWTH_FACTOR) + ) + for lru in (CACHED_TEMPLATE_LRU, CACHED_TEMPLATE_NO_COLLECT_LRU): + # There is no typing for LRU + current_size = lru.get_size() # type: ignore[attr-defined] + if new_size > current_size: + lru.set_size(new_size) # type: ignore[attr-defined] + + from .event import ( # pylint: disable=import-outside-toplevel + async_track_time_interval, + ) + + cancel = async_track_time_interval( + hass, _async_adjust_lru_sizes, timedelta(minutes=10) + ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_adjust_lru_sizes) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, callback(lambda _: cancel())) + return True + @bind_hass def attach(hass: HomeAssistant, obj: Any) -> None: @@ -969,21 +1038,33 @@ class TemplateStateFromEntityId(TemplateStateBase): return f"