From f80894d56f5e8213dc571bdab4b8aaccfc862c5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Apr 2024 02:41:13 -1000 Subject: [PATCH 01/64] Stop scripts with eager tasks (#115340) --- homeassistant/helpers/script.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 3c364ed8892..ea5cc3e571a 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1250,7 +1250,7 @@ async def _async_stop_scripts_after_shutdown( _LOGGER.warning("Stopping scripts running too long after shutdown: %s", names) await asyncio.gather( *( - script["instance"].async_stop(update_state=False) + create_eager_task(script["instance"].async_stop(update_state=False)) for script in running_scripts ) ) @@ -1269,7 +1269,10 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> names = ", ".join([script["instance"].name for script in running_scripts]) _LOGGER.debug("Stopping scripts running at shutdown: %s", names) await asyncio.gather( - *(script["instance"].async_stop() for script in running_scripts) + *( + create_eager_task(script["instance"].async_stop()) + for script in running_scripts + ) ) @@ -1695,6 +1698,9 @@ class Script: # return false after the other script runs were stopped until our task # resumes running. self._log("Restarting") + # Important: yield to the event loop to allow the script to start in case + # the script is restarting itself. + await asyncio.sleep(0) await self.async_stop(update_state=False, spare=run) if started_action: @@ -1724,11 +1730,13 @@ class Script: # asyncio.shield as asyncio.shield yields to the event loop, which would cause # us to wait for script runs added after the call to async_stop. aws = [ - asyncio.create_task(run.async_stop()) for run in self._runs if run != spare + create_eager_task(run.async_stop()) for run in self._runs if run != spare ] if not aws: return - await asyncio.shield(self._async_stop(aws, update_state, spare)) + await asyncio.shield( + create_eager_task(self._async_stop(aws, update_state, spare)) + ) async def _async_get_condition(self, config): if isinstance(config, template.Template): From 63545ceaa46140e487a6c1b4da4f3d1c740e3857 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Apr 2024 02:42:18 -1000 Subject: [PATCH 02/64] Ensure automations do not execute from a trigger if they are disabled (#115305) * Ensure automations are stopped as soon as the stop future is set * revert script changes and move them to #115325 --- .../components/automation/__init__.py | 18 ++++- tests/components/automation/test_init.py | 80 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 785d5849d74..299be2c82f9 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -795,6 +795,22 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): """Log helper callback.""" self._logger.log(level, "%s %s", msg, self.name, **kwargs) + async def _async_trigger_if_enabled( + self, + run_variables: dict[str, Any], + context: Context | None = None, + skip_condition: bool = False, + ) -> ScriptRunResult | None: + """Trigger automation if enabled. + + If the trigger starts but has a delay, the automation will be triggered + when the delay has passed so we need to make sure its still enabled before + executing the action. + """ + if not self._is_enabled: + return None + return await self.async_trigger(run_variables, context, skip_condition) + async def _async_attach_triggers( self, home_assistant_start: bool ) -> Callable[[], None] | None: @@ -818,7 +834,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): return await async_initialize_triggers( self.hass, self._trigger_config, - self.async_trigger, + self._async_trigger_if_enabled, DOMAIN, str(self.name), self._log_callback, diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 7805f3ea151..5b3fc2a723e 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2651,3 +2651,83 @@ def test_deprecated_constants( import_and_test_deprecated_constant( caplog, automation, constant_name, replacement.__name__, replacement, "2025.1" ) + + +async def test_automation_turns_off_other_automation( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test an automation that turns off another automation.""" + hass.set_state(CoreState.not_running) + calls = async_mock_service(hass, "persistent_notification", "create") + hass.states.async_set("binary_sensor.presence", "on") + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "state", + "entity_id": "binary_sensor.presence", + "from": "on", + }, + "action": { + "service": "automation.turn_off", + "target": { + "entity_id": "automation.automation_1", + }, + "data": { + "stop_actions": True, + }, + }, + "id": "automation_0", + "mode": "single", + }, + { + "trigger": { + "platform": "state", + "entity_id": "binary_sensor.presence", + "from": "on", + "for": { + "hours": 0, + "minutes": 0, + "seconds": 5, + }, + }, + "action": { + "service": "persistent_notification.create", + "metadata": {}, + "data": { + "message": "Test race", + }, + }, + "id": "automation_1", + "mode": "single", + }, + ] + }, + ) + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("binary_sensor.presence", "off") + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + assert len(calls) == 0 + + await hass.services.async_call( + "automation", + "turn_on", + {"entity_id": "automation.automation_1"}, + blocking=True, + ) + hass.states.async_set("binary_sensor.presence", "off") + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + assert len(calls) == 0 From b99cdf3144f04cdb3b8b9921071921c3342520a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Wed, 10 Apr 2024 17:52:08 +0200 Subject: [PATCH 03/64] Add missing oauth2 error strings to myuplink (#115315) Add some oauth2 error strings Co-authored-by: J. Nick Koston --- homeassistant/components/myuplink/strings.json | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index f01bb1990cc..2efc0d05b34 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -12,12 +12,15 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" From 8d6473061ca160b34e4bc8404c6a839c023f727b Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 10 Apr 2024 21:39:53 +0200 Subject: [PATCH 04/64] Solve modbus test problem (#115376) Fix test. --- tests/components/modbus/conftest.py | 12 +++++++- .../modbus/fixtures/configuration.yaml | 4 +++ tests/components/modbus/test_init.py | 28 ++++++++++--------- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index f6eff0fd64b..62cf12958d3 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -52,6 +52,15 @@ def mock_pymodbus_fixture(): """Mock pymodbus.""" mock_pb = mock.AsyncMock() mock_pb.close = mock.MagicMock() + read_result = ReadResult([]) + mock_pb.read_coils.return_value = read_result + mock_pb.read_discrete_inputs.return_value = read_result + mock_pb.read_input_registers.return_value = read_result + mock_pb.read_holding_registers.return_value = read_result + mock_pb.write_register.return_value = read_result + mock_pb.write_registers.return_value = read_result + mock_pb.write_coil.return_value = read_result + mock_pb.write_coils.return_value = read_result with ( mock.patch( "homeassistant.components.modbus.modbus.AsyncModbusTcpClient", @@ -156,7 +165,7 @@ async def mock_pymodbus_exception_fixture(hass, do_exception, mock_modbus): @pytest.fixture(name="mock_pymodbus_return") async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus): """Trigger update call with time_changed event.""" - read_result = ReadResult(register_words) if register_words else None + read_result = ReadResult(register_words if register_words else []) mock_modbus.read_coils.return_value = read_result mock_modbus.read_discrete_inputs.return_value = read_result mock_modbus.read_input_registers.return_value = read_result @@ -165,6 +174,7 @@ async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus): mock_modbus.write_registers.return_value = read_result mock_modbus.write_coil.return_value = read_result mock_modbus.write_coils.return_value = read_result + return mock_modbus @pytest.fixture(name="mock_do_cycle") diff --git a/tests/components/modbus/fixtures/configuration.yaml b/tests/components/modbus/fixtures/configuration.yaml index 0f12ac88686..0a16d85e39d 100644 --- a/tests/components/modbus/fixtures/configuration.yaml +++ b/tests/components/modbus/fixtures/configuration.yaml @@ -3,3 +3,7 @@ modbus: host: "testHost" port: 5001 name: "testModbus" + sensors: + - name: "dummy" + address: 117 + slave: 0 diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 922022741b0..f0dfd5357e7 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -1645,7 +1645,7 @@ async def test_shutdown( ], ) async def test_stop_restart( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return ) -> None: """Run test for service stop.""" @@ -1656,7 +1656,7 @@ async def test_stop_restart( await hass.async_block_till_done() assert hass.states.get(entity_id).state == "17" - mock_modbus.reset_mock() + mock_pymodbus_return.reset_mock() caplog.clear() data = { ATTR_HUB: TEST_MODBUS_NAME, @@ -1664,23 +1664,23 @@ async def test_stop_restart( await hass.services.async_call(DOMAIN, SERVICE_STOP, data, blocking=True) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - assert mock_modbus.close.called + assert mock_pymodbus_return.close.called assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text - mock_modbus.reset_mock() + mock_pymodbus_return.reset_mock() caplog.clear() await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True) await hass.async_block_till_done() - assert not mock_modbus.close.called - assert mock_modbus.connect.called + assert not mock_pymodbus_return.close.called + assert mock_pymodbus_return.connect.called assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text - mock_modbus.reset_mock() + mock_pymodbus_return.reset_mock() caplog.clear() await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True) await hass.async_block_till_done() - assert mock_modbus.close.called - assert mock_modbus.connect.called + assert mock_pymodbus_return.close.called + assert mock_pymodbus_return.connect.called assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text @@ -1710,7 +1710,7 @@ async def test_write_no_client(hass: HomeAssistant, mock_modbus) -> None: async def test_integration_reload( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mock_modbus, + mock_pymodbus_return, freezer: FrozenDateTimeFactory, ) -> None: """Run test for integration reload.""" @@ -1731,7 +1731,7 @@ async def test_integration_reload( @pytest.mark.parametrize("do_config", [{}]) async def test_integration_reload_failed( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return ) -> None: """Run test for integration connect failure on reload.""" caplog.set_level(logging.INFO) @@ -1740,7 +1740,9 @@ async def test_integration_reload_failed( yaml_path = get_fixture_path("configuration.yaml", "modbus") with ( mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), - mock.patch.object(mock_modbus, "connect", side_effect=ModbusException("error")), + mock.patch.object( + mock_pymodbus_return, "connect", side_effect=ModbusException("error") + ), ): await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) await hass.async_block_till_done() @@ -1751,7 +1753,7 @@ async def test_integration_reload_failed( @pytest.mark.parametrize("do_config", [{}]) async def test_integration_setup_failed( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return ) -> None: """Run test for integration setup on reload.""" with mock.patch.object( From 220801bf1c55f2dc2e2ec866dea27e54ef78ddc3 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 10 Apr 2024 22:09:10 +0200 Subject: [PATCH 05/64] Secure against resetting a non active modbus (#115364) --- homeassistant/components/modbus/__init__.py | 3 +++ tests/components/modbus/test_init.py | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 2a82cf89fd5..08e927bb553 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -463,6 +463,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_reset_platform(hass: HomeAssistant, integration_name: str) -> None: """Release modbus resources.""" + if DOMAIN not in hass.data: + _LOGGER.error("Modbus cannot reload, because it was never loaded") + return _LOGGER.info("Modbus reloading") hubs = hass.data[DOMAIN] for name in hubs: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index f0dfd5357e7..1219a04fb0c 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -25,6 +25,7 @@ import voluptuous as vol from homeassistant import config as hass_config from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.modbus import async_reset_platform from homeassistant.components.modbus.const import ( ATTR_ADDRESS, ATTR_HUB, @@ -1781,3 +1782,9 @@ async def test_no_entities(hass: HomeAssistant) -> None: ] } assert await async_setup_component(hass, DOMAIN, config) is False + + +async def test_reset_platform(hass: HomeAssistant) -> None: + """Run test for async_reset_platform.""" + await async_reset_platform(hass, "modbus") + assert DOMAIN not in hass.data From 6394e25f75926c8193d11481bc105555c0f30d0e Mon Sep 17 00:00:00 2001 From: On Freund Date: Thu, 11 Apr 2024 00:26:15 +0300 Subject: [PATCH 06/64] Improve Risco exception logging (#115232) --- homeassistant/components/risco/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 7ca18ea77c5..d25579343c8 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -101,7 +101,7 @@ async def _async_setup_local_entry(hass: HomeAssistant, entry: ConfigEntry) -> b return False async def _error(error: Exception) -> None: - _LOGGER.error("Error in Risco library: %s", error) + _LOGGER.error("Error in Risco library", exc_info=error) entry.async_on_unload(risco.add_error_handler(_error)) From 9d7e20f9cae3bdbf59fc2809b2e7ab177a24ae96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Apr 2024 11:38:34 -1000 Subject: [PATCH 07/64] Fix deadlock in holidays dynamic loading (#115385) --- homeassistant/components/holiday/__init__.py | 23 ++++++++++++++++- homeassistant/components/workday/__init__.py | 27 +++++++++++++++----- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py index 4f2c593d38e..c9a58f29215 100644 --- a/homeassistant/components/holiday/__init__.py +++ b/homeassistant/components/holiday/__init__.py @@ -2,15 +2,36 @@ from __future__ import annotations +from functools import partial + +from holidays import country_holidays + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_COUNTRY, Platform from homeassistant.core import HomeAssistant +from homeassistant.setup import SetupPhases, async_pause_setup + +from .const import CONF_PROVINCE PLATFORMS: list[Platform] = [Platform.CALENDAR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Holiday from a config entry.""" + country: str = entry.data[CONF_COUNTRY] + province: str | None = entry.data.get(CONF_PROVINCE) + + # We only import here to ensure that that its not imported later + # in the event loop since the platforms will call country_holidays + # which loads python code from disk. + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # import executor job is used here because multiple integrations use + # the holidays library and it is not thread safe to import it in parallel + # https://github.com/python/cpython/issues/83065 + await hass.async_add_import_executor_job( + partial(country_holidays, country, subdiv=province) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 077a6710b8d..f25cf41b992 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.setup import SetupPhases, async_pause_setup from .const import CONF_PROVINCE, DOMAIN, PLATFORMS @@ -23,7 +24,11 @@ async def _async_validate_country_and_province( if not country: return try: - await hass.async_add_executor_job(country_holidays, country) + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # import executor job is used here because multiple integrations use + # the holidays library and it is not thread safe to import it in parallel + # https://github.com/python/cpython/issues/83065 + await hass.async_add_import_executor_job(country_holidays, country) except NotImplementedError as ex: async_create_issue( hass, @@ -41,9 +46,13 @@ async def _async_validate_country_and_province( if not province: return try: - await hass.async_add_executor_job( - partial(country_holidays, country, subdiv=province) - ) + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # import executor job is used here because multiple integrations use + # the holidays library and it is not thread safe to import it in parallel + # https://github.com/python/cpython/issues/83065 + await hass.async_add_import_executor_job( + partial(country_holidays, country, subdiv=province) + ) except NotImplementedError as ex: async_create_issue( hass, @@ -73,9 +82,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await _async_validate_country_and_province(hass, entry, country, province) if country and CONF_LANGUAGE not in entry.options: - cls: HolidayBase = await hass.async_add_executor_job( - partial(country_holidays, country, subdiv=province) - ) + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # import executor job is used here because multiple integrations use + # the holidays library and it is not thread safe to import it in parallel + # https://github.com/python/cpython/issues/83065 + cls: HolidayBase = await hass.async_add_import_executor_job( + partial(country_holidays, country, subdiv=province) + ) default_language = cls.default_language new_options = entry.options.copy() new_options[CONF_LANGUAGE] = default_language From bbecb98927c23059c015eda8e78eeefa952e7ef9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Apr 2024 11:44:25 -1000 Subject: [PATCH 08/64] Fix type on known_object_ids in _entity_id_available and async_generate_entity_id (#115378) --- homeassistant/helpers/entity_platform.py | 4 ++-- homeassistant/helpers/entity_registry.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 261512c14af..ec4eef1f6a7 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -801,7 +801,7 @@ class EntityPlatform: get_initial_options=entity.get_initial_entity_options, has_entity_name=entity.has_entity_name, hidden_by=hidden_by, - known_object_ids=self.entities.keys(), + known_object_ids=self.entities, original_device_class=entity.device_class, original_icon=entity.icon, original_name=entity_name, @@ -839,7 +839,7 @@ class EntityPlatform: if self.entity_namespace is not None: suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" entity.entity_id = entity_registry.async_generate_entity_id( - self.domain, suggested_object_id, self.entities.keys() + self.domain, suggested_object_id, self.entities ) # Make sure it is valid in case an entity set the value themselves diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index d6e7395a2cb..3a26505c7da 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,7 +10,7 @@ timer. from __future__ import annotations -from collections.abc import Callable, Hashable, Iterable, KeysView, Mapping +from collections.abc import Callable, Container, Hashable, KeysView, Mapping from datetime import datetime, timedelta from enum import StrEnum from functools import cached_property @@ -714,7 +714,7 @@ class EntityRegistry(BaseRegistry): return list(self.entities.get_device_ids()) def _entity_id_available( - self, entity_id: str, known_object_ids: Iterable[str] | None + self, entity_id: str, known_object_ids: Container[str] | None ) -> bool: """Return True if the entity_id is available. @@ -740,7 +740,7 @@ class EntityRegistry(BaseRegistry): self, domain: str, suggested_object_id: str, - known_object_ids: Iterable[str] | None = None, + known_object_ids: Container[str] | None = None, ) -> str: """Generate an entity ID that does not conflict. @@ -753,7 +753,7 @@ class EntityRegistry(BaseRegistry): test_string = preferred_string[:MAX_LENGTH_STATE_ENTITY_ID] if known_object_ids is None: - known_object_ids = {} + known_object_ids = set() tries = 1 while not self._entity_id_available(test_string, known_object_ids): @@ -773,7 +773,7 @@ class EntityRegistry(BaseRegistry): unique_id: str, *, # To influence entity ID generation - known_object_ids: Iterable[str] | None = None, + known_object_ids: Container[str] | None = None, suggested_object_id: str | None = None, # To disable or hide an entity if it gets created disabled_by: RegistryEntryDisabler | None = None, From be8adf9d293274d5b7378b0c38ac705dfae6659e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 11 Apr 2024 01:10:46 +0200 Subject: [PATCH 09/64] Fix zha test by tweaking the log level (#115368) --- tests/components/zha/test_repairs.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index c254a9c15fe..5e128cc464a 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -265,17 +265,27 @@ async def test_no_warn_on_socket(hass: HomeAssistant) -> None: mock_probe.assert_not_called() -async def test_probe_failure_exception_handling(caplog) -> None: +async def test_probe_failure_exception_handling( + caplog: pytest.LogCaptureFixture, +) -> None: """Test that probe failures are handled gracefully.""" + logger = logging.getLogger( + "homeassistant.components.zha.repairs.wrong_silabs_firmware" + ) + orig_level = logger.level + with ( + caplog.at_level(logging.DEBUG), patch( "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", side_effect=RuntimeError(), - ), - caplog.at_level(logging.DEBUG), + ) as mock_probe_app_type, ): + logger.setLevel(logging.DEBUG) await probe_silabs_firmware_type("/dev/ttyZigbee") + logger.setLevel(orig_level) + mock_probe_app_type.assert_awaited() assert "Failed to probe application type" in caplog.text From e17c4ab4e31523701962438e070dea1063a4eff5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Apr 2024 14:18:11 -1000 Subject: [PATCH 10/64] Fix flakey tessie media_player test (#115391) --- tests/components/tessie/snapshots/test_media_player.ambr | 9 ++++++++- tests/components/tessie/test_media_player.py | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr index d30e6c74aef..6c355c8ddca 100644 --- a/tests/components/tessie/snapshots/test_media_player.ambr +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -54,6 +54,13 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Test Media player', + 'media_album_name': 'Album', + 'media_artist': 'Artist', + 'media_duration': 60.0, + 'media_playlist': 'Playlist', + 'media_position': 30.0, + 'media_title': 'Song', + 'source': 'Spotify', 'supported_features': , 'volume_level': 0.22580323309042688, }), @@ -62,6 +69,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'idle', + 'state': 'playing', }) # --- diff --git a/tests/components/tessie/test_media_player.py b/tests/components/tessie/test_media_player.py index c9e4c3b84bc..008607b8018 100644 --- a/tests/components/tessie/test_media_player.py +++ b/tests/components/tessie/test_media_player.py @@ -22,6 +22,8 @@ async def test_media_player( freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_get_state, + mock_get_status, ) -> None: """Tests that the media player entity is correct when idle.""" @@ -38,6 +40,7 @@ async def test_media_player( # The refresh fixture has music playing freezer.tick(WAIT) async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_entry.entity_id) == snapshot( name=f"{entity_entry.entity_id}-playing" From 288f3d84ba3c566468430556fdb826eac5690230 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Apr 2024 15:15:21 -1000 Subject: [PATCH 11/64] Fix duplicate automation entity state writes (#115386) _async_attach_triggers was writing state, async_enable was writing state, and all of them were called async_added_to_hass After entity calls async_added_to_hass via add_to_platform_finish it will also write state so there were some paths that did it 3x async_disable was also writing state when the entity was removed --- .../components/automation/__init__.py | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 299be2c82f9..afc8f9aba10 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -604,18 +604,20 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): ) if enable_automation: - await self.async_enable() + await self._async_enable() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on and update the state.""" - await self.async_enable() + await self._async_enable() + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" if CONF_STOP_ACTIONS in kwargs: - await self.async_disable(kwargs[CONF_STOP_ACTIONS]) + await self._async_disable(kwargs[CONF_STOP_ACTIONS]) else: - await self.async_disable() + await self._async_disable() + self.async_write_ha_state() async def async_trigger( self, @@ -743,7 +745,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): async def async_will_remove_from_hass(self) -> None: """Remove listeners when removing automation from Home Assistant.""" await super().async_will_remove_from_hass() - await self.async_disable() + await self._async_disable() async def _async_enable_automation(self, event: Event) -> None: """Start automation on startup.""" @@ -752,31 +754,34 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): return self._async_detach_triggers = await self._async_attach_triggers(True) + self.async_write_ha_state() - async def async_enable(self) -> None: + async def _async_enable(self) -> None: """Enable this automation entity. - This method is a coroutine. + This method is not expected to write state to the + state machine. """ if self._is_enabled: return self._is_enabled = True - # HomeAssistant is starting up if self.hass.state is not CoreState.not_running: self._async_detach_triggers = await self._async_attach_triggers(False) - self.async_write_ha_state() return self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, self._async_enable_automation, ) - self.async_write_ha_state() - async def async_disable(self, stop_actions: bool = DEFAULT_STOP_ACTIONS) -> None: - """Disable the automation entity.""" + async def _async_disable(self, stop_actions: bool = DEFAULT_STOP_ACTIONS) -> None: + """Disable the automation entity. + + This method is not expected to write state to the + state machine. + """ if not self._is_enabled and not self.action_script.runs: return @@ -789,8 +794,6 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): if stop_actions: await self.action_script.async_stop() - self.async_write_ha_state() - def _log_callback(self, level: int, msg: str, **kwargs: Any) -> None: """Log helper callback.""" self._logger.log(level, "%s %s", msg, self.name, **kwargs) @@ -816,7 +819,6 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): ) -> Callable[[], None] | None: """Set up the triggers.""" this = None - self.async_write_ha_state() if state := self.hass.states.get(self.entity_id): this = state.as_dict() variables = {"this": this} From 6bd6adc4f54b013d0ce544ac78e4c326c2d39daf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Apr 2024 15:18:47 -1000 Subject: [PATCH 12/64] Avoid calling valid_entity_id when adding entities if they are already registered (#115388) --- homeassistant/helpers/entity_platform.py | 4 +++- tests/helpers/test_entity_platform.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ec4eef1f6a7..2b9a5d436ed 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -843,7 +843,9 @@ class EntityPlatform: ) # Make sure it is valid in case an entity set the value themselves - if not valid_entity_id(entity.entity_id): + # Avoid calling valid_entity_id if we already know it is valid + # since it already made it in the registry + if not entity.registry_entry and not valid_entity_id(entity.entity_id): entity.add_to_platform_abort() raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}") diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 59c4f7357f3..64f6d6bf9f5 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1112,6 +1112,19 @@ async def test_entity_registry_updates_invalid_entity_id(hass: HomeAssistant) -> assert hass.states.get("diff_domain.world") is None +async def test_add_entity_with_invalid_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trying to add an entity with an invalid entity_id.""" + platform = MockEntityPlatform(hass) + entity = MockEntity(entity_id="i.n.v.a.l.i.d") + await platform.async_add_entities([entity]) + assert ( + "Error adding entity i.n.v.a.l.i.d for domain " + "test_domain with platform test_platform" in caplog.text + ) + + async def test_device_info_called( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: From f0c8c2a6845101e30c7f8d74c69e807bce16d299 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Apr 2024 15:19:17 -1000 Subject: [PATCH 13/64] Adjust importlib helper to avoid leaking memory on re-raise (#115377) --- homeassistant/helpers/importlib.py | 11 +++++------ tests/helpers/test_importlib.py | 30 +++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/importlib.py b/homeassistant/helpers/importlib.py index 00af75f6d8e..98c75939084 100644 --- a/homeassistant/helpers/importlib.py +++ b/homeassistant/helpers/importlib.py @@ -30,11 +30,9 @@ async def async_import_module(hass: HomeAssistant, name: str) -> ModuleType: if module := cache.get(name): return module - failure_cache: dict[str, BaseException] = hass.data.setdefault( - DATA_IMPORT_FAILURES, {} - ) - if exception := failure_cache.get(name): - raise exception + failure_cache: dict[str, bool] = hass.data.setdefault(DATA_IMPORT_FAILURES, {}) + if name in failure_cache: + raise ModuleNotFoundError(f"{name} not found", name=name) import_futures: dict[str, asyncio.Future[ModuleType]] import_futures = hass.data.setdefault(DATA_IMPORT_FUTURES, {}) @@ -51,7 +49,8 @@ async def async_import_module(hass: HomeAssistant, name: str) -> ModuleType: module = await hass.async_add_import_executor_job(_get_module, cache, name) import_future.set_result(module) except BaseException as ex: - failure_cache[name] = ex + if isinstance(ex, ModuleNotFoundError): + failure_cache[name] = True import_future.set_exception(ex) with suppress(BaseException): # Set the exception retrieved flag on the future since diff --git a/tests/helpers/test_importlib.py b/tests/helpers/test_importlib.py index 5683dd5cf94..5c9686233f9 100644 --- a/tests/helpers/test_importlib.py +++ b/tests/helpers/test_importlib.py @@ -41,16 +41,40 @@ async def test_async_import_module_failures(hass: HomeAssistant) -> None: with ( patch( "homeassistant.helpers.importlib.importlib.import_module", - side_effect=ImportError, + side_effect=ValueError, ), - pytest.raises(ImportError), + pytest.raises(ValueError), + ): + await importlib.async_import_module(hass, "test.module") + + mock_module = MockModule() + # The failure should be not be cached + with ( + patch( + "homeassistant.helpers.importlib.importlib.import_module", + return_value=mock_module, + ), + ): + assert await importlib.async_import_module(hass, "test.module") is mock_module + + +async def test_async_import_module_failure_caches_module_not_found( + hass: HomeAssistant, +) -> None: + """Test importing a module caches ModuleNotFound.""" + with ( + patch( + "homeassistant.helpers.importlib.importlib.import_module", + side_effect=ModuleNotFoundError, + ), + pytest.raises(ModuleNotFoundError), ): await importlib.async_import_module(hass, "test.module") mock_module = MockModule() # The failure should be cached with ( - pytest.raises(ImportError), + pytest.raises(ModuleNotFoundError), patch( "homeassistant.helpers.importlib.importlib.import_module", return_value=mock_module, From d9b74fda8965c46efb1c640243d595225694f6c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Apr 2024 19:26:30 -1000 Subject: [PATCH 14/64] Add PYTHONASYNCIODEBUG to the dev container env (#115392) --- .devcontainer/devcontainer.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 83aa88140cc..2bdb6f99aad 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,10 @@ "dockerFile": "../Dockerfile.dev", "postCreateCommand": "script/setup", "postStartCommand": "script/bootstrap", - "containerEnv": { "DEVCONTAINER": "1" }, + "containerEnv": { + "DEVCONTAINER": "1", + "PYTHONASYNCIODEBUG": "1" + }, // Port 5683 udp is used by Shelly integration "appPort": ["8123:8123", "5683:5683/udp"], "runArgs": ["-e", "GIT_EDITOR=code --wait"], From 4224234b7abfd1b31f75637b910f4fb89d5b4a0d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 11 Apr 2024 07:46:07 +0200 Subject: [PATCH 15/64] Add binary sensor to Netatmo (#115119) * Add binary sensor to Netatmo * Update homeassistant/components/netatmo/binary_sensor.py Co-authored-by: Jan-Philipp Benecke * Sigh * Fix * Fix * Fix --------- Co-authored-by: Jan-Philipp Benecke --- .../components/netatmo/binary_sensor.py | 60 ++ homeassistant/components/netatmo/const.py | 1 + homeassistant/components/netatmo/entity.py | 42 +- homeassistant/components/netatmo/sensor.py | 38 +- .../netatmo/snapshots/test_binary_sensor.ambr | 541 ++++++++++++++++++ .../components/netatmo/test_binary_sensor.py | 31 + 6 files changed, 680 insertions(+), 33 deletions(-) create mode 100644 homeassistant/components/netatmo/binary_sensor.py create mode 100644 tests/components/netatmo/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/netatmo/test_binary_sensor.py diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py new file mode 100644 index 00000000000..c478525753a --- /dev/null +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -0,0 +1,60 @@ +"""Support for Netatmo binary sensors.""" + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import NETATMO_CREATE_WEATHER_SENSOR +from .data_handler import NetatmoDevice +from .entity import NetatmoWeatherModuleEntity + +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="reachable", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Netatmo binary sensors based on a config entry.""" + + @callback + def _create_weather_binary_sensor_entity(netatmo_device: NetatmoDevice) -> None: + async_add_entities( + NetatmoWeatherBinarySensor(netatmo_device, description) + for description in BINARY_SENSOR_TYPES + if description.key in netatmo_device.device.features + ) + + entry.async_on_unload( + async_dispatcher_connect( + hass, NETATMO_CREATE_WEATHER_SENSOR, _create_weather_binary_sensor_entity + ) + ) + + +class NetatmoWeatherBinarySensor(NetatmoWeatherModuleEntity, BinarySensorEntity): + """Implementation of a Netatmo binary sensor.""" + + def __init__( + self, device: NetatmoDevice, description: BinarySensorEntityDescription + ) -> None: + """Initialize a Netatmo binary sensor.""" + super().__init__(device) + self.entity_description = description + self._attr_unique_id = f"{self.device.entity_id}-{description.key}" + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + self._attr_is_on = self.device.reachable + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 8109b418066..74f2ebc84b2 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -9,6 +9,7 @@ MANUFACTURER = "Netatmo" DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}" PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.CAMERA, Platform.CLIMATE, Platform.COVER, diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index 5f08cb941d6..6fdebcf0c3f 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -3,12 +3,13 @@ from __future__ import annotations from abc import abstractmethod -from typing import Any +from typing import Any, cast from pyatmo import DeviceType, Home, Module, Room -from pyatmo.modules.base_class import NetatmoBase +from pyatmo.modules.base_class import NetatmoBase, Place from pyatmo.modules.device_types import DEVICE_DESCRIPTION_MAP +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -16,6 +17,7 @@ from homeassistant.helpers.entity import Entity from .const import ( CONF_URL_ENERGY, + CONF_URL_WEATHER, DATA_DEVICE_IDS, DEFAULT_ATTRIBUTION, DOMAIN, @@ -166,3 +168,39 @@ class NetatmoModuleEntity(NetatmoDeviceEntity): def device_type(self) -> DeviceType: """Return the device type.""" return self.device.device_type + + +class NetatmoWeatherModuleEntity(NetatmoModuleEntity): + """Netatmo weather module entity base class.""" + + _attr_configuration_url = CONF_URL_WEATHER + + def __init__(self, device: NetatmoDevice) -> None: + """Set up a Netatmo weather module entity.""" + super().__init__(device) + category = getattr(self.device.device_category, "name") + self._publishers.extend( + [ + { + "name": category, + SIGNAL_NAME: category, + }, + ] + ) + + if hasattr(self.device, "place"): + place = cast(Place, getattr(self.device, "place")) + if hasattr(place, "location") and place.location is not None: + self._attr_extra_state_attributes.update( + { + ATTR_LATITUDE: place.location.latitude, + ATTR_LONGITUDE: place.location.longitude, + } + ) + + @property + def device_type(self) -> DeviceType: + """Return the Netatmo device type.""" + if "." not in self.device.device_type: + return super().device_type + return DeviceType(self.device.device_type.partition(".")[2]) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 7e7b6029572..4e470437f7a 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -8,7 +8,6 @@ import logging from typing import Any, cast import pyatmo -from pyatmo import DeviceType from pyatmo.modules import PublicWeatherArea from homeassistant.components.sensor import ( @@ -48,7 +47,6 @@ from homeassistant.helpers.typing import StateType from .const import ( CONF_URL_ENERGY, CONF_URL_PUBLIC_WEATHER, - CONF_URL_WEATHER, CONF_WEATHER_AREAS, DATA_HANDLER, DOMAIN, @@ -59,7 +57,12 @@ from .const import ( SIGNAL_NAME, ) from .data_handler import HOME, PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom -from .entity import NetatmoBaseEntity, NetatmoModuleEntity, NetatmoRoomEntity +from .entity import ( + NetatmoBaseEntity, + NetatmoModuleEntity, + NetatmoRoomEntity, + NetatmoWeatherModuleEntity, +) from .helper import NetatmoArea _LOGGER = logging.getLogger(__name__) @@ -491,11 +494,10 @@ async def async_setup_entry( await add_public_entities(False) -class NetatmoWeatherSensor(NetatmoModuleEntity, SensorEntity): +class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, SensorEntity): """Implementation of a Netatmo weather/home coach sensor.""" entity_description: NetatmoSensorEntityDescription - _attr_configuration_url = CONF_URL_WEATHER def __init__( self, @@ -506,34 +508,8 @@ class NetatmoWeatherSensor(NetatmoModuleEntity, SensorEntity): super().__init__(netatmo_device) self.entity_description = description self._attr_translation_key = description.netatmo_name - category = getattr(self.device.device_category, "name") - self._publishers.extend( - [ - { - "name": category, - SIGNAL_NAME: category, - }, - ] - ) self._attr_unique_id = f"{self.device.entity_id}-{description.key}" - if hasattr(self.device, "place"): - place = cast(pyatmo.modules.base_class.Place, getattr(self.device, "place")) - if hasattr(place, "location") and place.location is not None: - self._attr_extra_state_attributes.update( - { - ATTR_LATITUDE: place.location.latitude, - ATTR_LONGITUDE: place.location.longitude, - } - ) - - @property - def device_type(self) -> DeviceType: - """Return the Netatmo device type.""" - if "." not in self.device.device_type: - return super().device_type - return DeviceType(self.device.device_type.partition(".")[2]) - @property def available(self) -> bool: """Return True if entity is available.""" diff --git a/tests/components/netatmo/snapshots/test_binary_sensor.ambr b/tests/components/netatmo/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..6a90b4dd77a --- /dev/null +++ b/tests/components/netatmo/snapshots/test_binary_sensor.ambr @@ -0,0 +1,541 @@ +# serializer version: 1 +# name: test_entity[binary_sensor.baby_bedroom_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.baby_bedroom_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[binary_sensor.baby_bedroom_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'connectivity', + 'friendly_name': 'Baby Bedroom Connectivity', + 'latitude': 13.377726, + 'longitude': 52.516263, + }), + 'context': , + 'entity_id': 'binary_sensor.baby_bedroom_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity[binary_sensor.bedroom_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.bedroom_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[binary_sensor.bedroom_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'connectivity', + 'friendly_name': 'Bedroom Connectivity', + 'latitude': 13.377726, + 'longitude': 52.516263, + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity[binary_sensor.kitchen_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kitchen_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[binary_sensor.kitchen_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'connectivity', + 'friendly_name': 'Kitchen Connectivity', + 'latitude': 13.377726, + 'longitude': 52.516263, + }), + 'context': , + 'entity_id': 'binary_sensor.kitchen_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity[binary_sensor.livingroom_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.livingroom_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[binary_sensor.livingroom_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'connectivity', + 'friendly_name': 'Livingroom Connectivity', + 'latitude': 13.377726, + 'longitude': 52.516263, + }), + 'context': , + 'entity_id': 'binary_sensor.livingroom_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity[binary_sensor.parents_bedroom_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.parents_bedroom_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[binary_sensor.parents_bedroom_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'connectivity', + 'friendly_name': 'Parents Bedroom Connectivity', + 'latitude': 13.377726, + 'longitude': 52.516263, + }), + 'context': , + 'entity_id': 'binary_sensor.parents_bedroom_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity[binary_sensor.villa_bathroom_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.villa_bathroom_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[binary_sensor.villa_bathroom_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'connectivity', + 'friendly_name': 'Villa Bathroom Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.villa_bathroom_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity[binary_sensor.villa_bedroom_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.villa_bedroom_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[binary_sensor.villa_bedroom_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'connectivity', + 'friendly_name': 'Villa Bedroom Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.villa_bedroom_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity[binary_sensor.villa_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.villa_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[binary_sensor.villa_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'connectivity', + 'friendly_name': 'Villa Connectivity', + 'latitude': 46.123456, + 'longitude': 6.1234567, + }), + 'context': , + 'entity_id': 'binary_sensor.villa_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity[binary_sensor.villa_garden_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.villa_garden_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[binary_sensor.villa_garden_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'connectivity', + 'friendly_name': 'Villa Garden Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.villa_garden_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity[binary_sensor.villa_outdoor_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.villa_outdoor_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[binary_sensor.villa_outdoor_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'connectivity', + 'friendly_name': 'Villa Outdoor Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.villa_outdoor_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity[binary_sensor.villa_rain_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.villa_rain_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[binary_sensor.villa_rain_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'connectivity', + 'friendly_name': 'Villa Rain Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.villa_rain_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/netatmo/test_binary_sensor.py b/tests/components/netatmo/test_binary_sensor.py new file mode 100644 index 00000000000..53aea461fde --- /dev/null +++ b/tests/components/netatmo/test_binary_sensor.py @@ -0,0 +1,31 @@ +"""Support for Netatmo binary sensors.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.netatmo.common import snapshot_platform_entities + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.BINARY_SENSOR, + entity_registry, + snapshot, + ) From 3546ca386f9d8a629859302b24967045a1f93eb6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 11 Apr 2024 09:40:16 +0200 Subject: [PATCH 16/64] Use freezer on diagnostics test (#115398) * Use freezer on diagnostics test * Patch correctly --- tests/components/rtsp_to_webrtc/test_diagnostics.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/rtsp_to_webrtc/test_diagnostics.py b/tests/components/rtsp_to_webrtc/test_diagnostics.py index e020ebfd5f3..8af6b914191 100644 --- a/tests/components/rtsp_to_webrtc/test_diagnostics.py +++ b/tests/components/rtsp_to_webrtc/test_diagnostics.py @@ -2,6 +2,8 @@ from typing import Any +from freezegun.api import FrozenDateTimeFactory + from homeassistant.core import HomeAssistant from .conftest import ComponentSetup @@ -19,6 +21,7 @@ async def test_entry_diagnostics( config_entry: MockConfigEntry, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup, + freezer: FrozenDateTimeFactory, ) -> None: """Test config entry diagnostics.""" await setup_integration() From 6954fcc8ad35424e21787b1217b8110a7c880fa0 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Apr 2024 09:10:56 +0100 Subject: [PATCH 17/64] Add strict typing to ring integration (#115276) --- .strict-typing | 1 + homeassistant/components/ring/__init__.py | 47 ++-- .../components/ring/binary_sensor.py | 62 +++-- homeassistant/components/ring/button.py | 21 +- homeassistant/components/ring/camera.py | 74 ++--- homeassistant/components/ring/config_flow.py | 14 +- homeassistant/components/ring/const.py | 6 - homeassistant/components/ring/coordinator.py | 74 +++-- homeassistant/components/ring/diagnostics.py | 12 +- homeassistant/components/ring/entity.py | 52 ++-- homeassistant/components/ring/light.py | 46 +-- homeassistant/components/ring/sensor.py | 261 +++++++++--------- homeassistant/components/ring/siren.py | 25 +- homeassistant/components/ring/switch.py | 36 +-- mypy.ini | 10 + tests/components/ring/common.py | 2 +- 16 files changed, 384 insertions(+), 359 deletions(-) diff --git a/.strict-typing b/.strict-typing index b1d6df7c9b8..63a867e9c50 100644 --- a/.strict-typing +++ b/.strict-typing @@ -363,6 +363,7 @@ homeassistant.components.rest_command.* homeassistant.components.rfxtrx.* homeassistant.components.rhasspy.* homeassistant.components.ridwell.* +homeassistant.components.ring.* homeassistant.components.rituals_perfume_genie.* homeassistant.components.roku.* homeassistant.components.romy.* diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index ffa99704526..36c66550ddc 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -2,10 +2,12 @@ from __future__ import annotations +from dataclasses import dataclass from functools import partial import logging +from typing import Any, cast -from ring_doorbell import Auth, Ring +from ring_doorbell import Auth, Ring, RingDevices from homeassistant.config_entries import ConfigEntry from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__ @@ -13,23 +15,26 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import ( - DOMAIN, - PLATFORMS, - RING_API, - RING_DEVICES, - RING_DEVICES_COORDINATOR, - RING_NOTIFICATIONS_COORDINATOR, -) +from .const import DOMAIN, PLATFORMS from .coordinator import RingDataCoordinator, RingNotificationsCoordinator _LOGGER = logging.getLogger(__name__) +@dataclass +class RingData: + """Class to support type hinting of ring data collection.""" + + api: Ring + devices: RingDevices + devices_coordinator: RingDataCoordinator + notifications_coordinator: RingNotificationsCoordinator + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - def token_updater(token): + def token_updater(token: dict[str, Any]) -> None: """Handle from sync context when token is updated.""" hass.loop.call_soon_threadsafe( partial( @@ -51,12 +56,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await devices_coordinator.async_config_entry_first_refresh() await notifications_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - RING_API: ring, - RING_DEVICES: ring.devices(), - RING_DEVICES_COORDINATOR: devices_coordinator, - RING_NOTIFICATIONS_COORDINATOR: notifications_coordinator, - } + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RingData( + api=ring, + devices=ring.devices(), + devices_coordinator=devices_coordinator, + notifications_coordinator=notifications_coordinator, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -83,8 +88,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) for info in hass.data[DOMAIN].values(): - await info[RING_DEVICES_COORDINATOR].async_refresh() - await info[RING_NOTIFICATIONS_COORDINATOR].async_refresh() + ring_data = cast(RingData, info) + await ring_data.devices_coordinator.async_refresh() + await ring_data.notifications_coordinator.async_refresh() # register service hass.services.async_register(DOMAIN, "update", async_refresh_all) @@ -121,8 +127,9 @@ async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None: @callback def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, str] | None: # Old format for camera and light was int - if isinstance(entity_entry.unique_id, int): - new_unique_id = str(entity_entry.unique_id) + unique_id = cast(str | int, entity_entry.unique_id) + if isinstance(unique_id, int): + new_unique_id = str(unique_id) if existing_entity_id := entity_registry.async_get_entity_id( entity_entry.domain, entity_entry.platform, new_unique_id ): diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 19daebf9ce1..2db04cfd461 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -2,10 +2,13 @@ from __future__ import annotations +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime from typing import Any +from ring_doorbell import Ring, RingEvent, RingGeneric + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -15,29 +18,32 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATOR +from . import RingData +from .const import DOMAIN from .coordinator import RingNotificationsCoordinator -from .entity import RingEntity +from .entity import RingBaseEntity @dataclass(frozen=True, kw_only=True) class RingBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Ring binary sensor entity.""" - category: list[str] + exists_fn: Callable[[RingGeneric], bool] BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( RingBinarySensorEntityDescription( key="ding", translation_key="ding", - category=["doorbots", "authorized_doorbots", "other"], device_class=BinarySensorDeviceClass.OCCUPANCY, + exists_fn=lambda device: device.family + in {"doorbots", "authorized_doorbots", "other"}, ), RingBinarySensorEntityDescription( key="motion", - category=["doorbots", "authorized_doorbots", "stickup_cams"], device_class=BinarySensorDeviceClass.MOTION, + exists_fn=lambda device: device.family + in {"doorbots", "authorized_doorbots", "stickup_cams"}, ), ) @@ -48,34 +54,36 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ring binary sensors from a config entry.""" - ring = hass.data[DOMAIN][config_entry.entry_id][RING_API] - devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] - notifications_coordinator: RingNotificationsCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ][RING_NOTIFICATIONS_COORDINATOR] + ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] entities = [ - RingBinarySensor(ring, device, notifications_coordinator, description) - for device_type in ("doorbots", "authorized_doorbots", "stickup_cams", "other") + RingBinarySensor( + ring_data.api, + device, + ring_data.notifications_coordinator, + description, + ) for description in BINARY_SENSOR_TYPES - if device_type in description.category - for device in devices[device_type] + for device in ring_data.devices.all_devices + if description.exists_fn(device) ] async_add_entities(entities) -class RingBinarySensor(RingEntity, BinarySensorEntity): +class RingBinarySensor( + RingBaseEntity[RingNotificationsCoordinator], BinarySensorEntity +): """A binary sensor implementation for Ring device.""" - _active_alert: dict[str, Any] | None = None + _active_alert: RingEvent | None = None entity_description: RingBinarySensorEntityDescription def __init__( self, - ring, - device, - coordinator, + ring: Ring, + device: RingGeneric, + coordinator: RingNotificationsCoordinator, description: RingBinarySensorEntityDescription, ) -> None: """Initialize a sensor for Ring device.""" @@ -89,13 +97,13 @@ class RingBinarySensor(RingEntity, BinarySensorEntity): self._update_alert() @callback - def _handle_coordinator_update(self, _=None): + def _handle_coordinator_update(self, _: Any = None) -> None: """Call update method.""" self._update_alert() super()._handle_coordinator_update() @callback - def _update_alert(self): + def _update_alert(self) -> None: """Update active alert.""" self._active_alert = next( ( @@ -108,21 +116,23 @@ class RingBinarySensor(RingEntity, BinarySensorEntity): ) @property - def is_on(self): + def is_on(self) -> bool: """Return True if the binary sensor is on.""" return self._active_alert is not None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" attrs = super().extra_state_attributes if self._active_alert is None: return attrs + assert isinstance(attrs, dict) attrs["state"] = self._active_alert["state"] - attrs["expires_at"] = datetime.fromtimestamp( - self._active_alert.get("now") + self._active_alert.get("expires_in") - ).isoformat() + now = self._active_alert.get("now") + expires_in = self._active_alert.get("expires_in") + assert now and expires_in + attrs["expires_at"] = datetime.fromtimestamp(now + expires_in).isoformat() return attrs diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py index d739dc29841..a14853a0881 100644 --- a/homeassistant/components/ring/button.py +++ b/homeassistant/components/ring/button.py @@ -2,12 +2,15 @@ from __future__ import annotations +from ring_doorbell import RingOther + from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from . import RingData +from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -22,14 +25,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the buttons for the Ring devices.""" - devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] - devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ - RING_DEVICES_COORDINATOR - ] + ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + devices_coordinator = ring_data.devices_coordinator async_add_entities( RingDoorButton(device, devices_coordinator, BUTTON_DESCRIPTION) - for device in devices["other"] + for device in ring_data.devices.other if device.has_capability("open") ) @@ -37,10 +38,12 @@ async def async_setup_entry( class RingDoorButton(RingEntity, ButtonEntity): """Creates a button to open the ring intercom door.""" + _device: RingOther + def __init__( self, - device, - coordinator, + device: RingOther, + coordinator: RingDataCoordinator, description: ButtonEntityDescription, ) -> None: """Initialize the button.""" @@ -52,6 +55,6 @@ class RingDoorButton(RingEntity, ButtonEntity): self._attr_unique_id = f"{device.id}-{description.key}" @exception_wrap - def press(self): + def press(self) -> None: """Open the door.""" self._device.open_door() diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index b9d73afe6de..297e5f47627 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -3,11 +3,12 @@ from __future__ import annotations from datetime import timedelta -from itertools import chain import logging -from typing import Optional +from typing import Any +from aiohttp import web from haffmpeg.camera import CameraMjpeg +from ring_doorbell import RingDoorBell from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera @@ -17,7 +18,8 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from . import RingData +from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -33,20 +35,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" - devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] - devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ - RING_DEVICES_COORDINATOR - ] + ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + devices_coordinator = ring_data.devices_coordinator ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) - cams = [] - for camera in chain( - devices["doorbots"], devices["authorized_doorbots"], devices["stickup_cams"] - ): - if not camera.has_subscription: - continue - - cams.append(RingCam(camera, devices_coordinator, ffmpeg_manager)) + cams = [ + RingCam(camera, devices_coordinator, ffmpeg_manager) + for camera in ring_data.devices.video_devices + if camera.has_subscription + ] async_add_entities(cams) @@ -55,28 +52,34 @@ class RingCam(RingEntity, Camera): """An implementation of a Ring Door Bell camera.""" _attr_name = None + _device: RingDoorBell - def __init__(self, device, coordinator, ffmpeg_manager): + def __init__( + self, + device: RingDoorBell, + coordinator: RingDataCoordinator, + ffmpeg_manager: ffmpeg.FFmpegManager, + ) -> None: """Initialize a Ring Door Bell camera.""" super().__init__(device, coordinator) Camera.__init__(self) - self._ffmpeg_manager = ffmpeg_manager - self._last_event = None - self._last_video_id = None - self._video_url = None - self._image = None + self._last_event: dict[str, Any] | None = None + self._last_video_id: int | None = None + self._video_url: str | None = None + self._image: bytes | None = None self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL self._attr_unique_id = str(device.id) if device.has_capability(MOTION_DETECTION_CAPABILITY): self._attr_motion_detection_enabled = device.motion_detection @callback - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: """Call update method.""" - history_data: Optional[list] - if not (history_data := self._get_coordinator_history()): - return + self._device = self._get_coordinator_data().get_video_device( + self._device.device_api_id + ) + history_data = self._device.last_history if history_data: self._last_event = history_data[0] self.async_schedule_update_ha_state(True) @@ -89,7 +92,7 @@ class RingCam(RingEntity, Camera): self.async_write_ha_state() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { "video_url": self._video_url, @@ -100,7 +103,7 @@ class RingCam(RingEntity, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - if self._image is None and self._video_url: + if self._image is None and self._video_url is not None: image = await ffmpeg.async_get_image( self.hass, self._video_url, @@ -113,10 +116,12 @@ class RingCam(RingEntity, Camera): return self._image - async def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream( + self, request: web.Request + ) -> web.StreamResponse | None: """Generate an HTTP MJPEG stream from the camera.""" if self._video_url is None: - return + return None stream = CameraMjpeg(self._ffmpeg_manager.binary) await stream.open_camera(self._video_url) @@ -132,7 +137,7 @@ class RingCam(RingEntity, Camera): finally: await stream.close() - async def async_update(self): + async def async_update(self) -> None: """Update camera entity and refresh attributes.""" if ( self._device.has_capability(MOTION_DETECTION_CAPABILITY) @@ -160,11 +165,14 @@ class RingCam(RingEntity, Camera): self._expires_at = FORCE_REFRESH_INTERVAL + utcnow @exception_wrap - def _get_video(self): - return self._device.recording_url(self._last_event["id"]) + def _get_video(self) -> str | None: + if self._last_event is None: + return None + assert (event_id := self._last_event.get("id")) and isinstance(event_id, int) + return self._device.recording_url(event_id) @exception_wrap - def _set_motion_detection_enabled(self, new_state): + def _set_motion_detection_enabled(self, new_state: bool) -> None: if not self._device.has_capability(MOTION_DETECTION_CAPABILITY): _LOGGER.error( "Entity %s does not have motion detection capability", self.entity_id diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index 6d4f28eb311..4762017c5bc 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -28,7 +28,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) -async def validate_input(hass: HomeAssistant, data): +async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Any]: """Validate the user input allows us to connect.""" auth = Auth(f"{APPLICATION_NAME}/{ha_version}") @@ -56,9 +56,11 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): user_pass: dict[str, Any] = {} reauth_entry: ConfigEntry | None = None - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: token = await validate_input(self.hass, user_input) @@ -82,7 +84,9 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_2fa(self, user_input=None): + async def async_step_2fa( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle 2fa step.""" if user_input: if self.reauth_entry: @@ -110,7 +114,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" - errors = {} + errors: dict[str, str] = {} assert self.reauth_entry is not None if user_input: diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index 23f378a38be..70813a78c76 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -28,10 +28,4 @@ PLATFORMS = [ SCAN_INTERVAL = timedelta(minutes=1) NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5) -RING_API = "api" -RING_DEVICES = "devices" - -RING_DEVICES_COORDINATOR = "device_data" -RING_NOTIFICATIONS_COORDINATOR = "dings_data" - CONF_2FA = "2fa" diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index fdb6fc1f296..a10f9317bab 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -2,11 +2,10 @@ from asyncio import TaskGroup from collections.abc import Callable -from dataclasses import dataclass import logging -from typing import Any, Optional +from typing import TypeVar, TypeVarTuple -from ring_doorbell import AuthenticationError, Ring, RingError, RingGeneric, RingTimeout +from ring_doorbell import AuthenticationError, Ring, RingDevices, RingError, RingTimeout from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -16,10 +15,13 @@ from .const import NOTIFICATIONS_SCAN_INTERVAL, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +_R = TypeVar("_R") +_Ts = TypeVarTuple("_Ts") + async def _call_api( - hass: HomeAssistant, target: Callable[..., Any], *args, msg_suffix: str = "" -): + hass: HomeAssistant, target: Callable[[*_Ts], _R], *args: *_Ts, msg_suffix: str = "" +) -> _R: try: return await hass.async_add_executor_job(target, *args) except AuthenticationError as err: @@ -34,15 +36,7 @@ async def _call_api( raise UpdateFailed(f"Error communicating with API{msg_suffix}: {err}") from err -@dataclass -class RingDeviceData: - """RingDeviceData.""" - - device: RingGeneric - history: Optional[list] = None - - -class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]): +class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): """Base class for device coordinators.""" def __init__( @@ -60,45 +54,39 @@ class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]): self.ring_api: Ring = ring_api self.first_call: bool = True - async def _async_update_data(self): + async def _async_update_data(self) -> RingDevices: """Fetch data from API endpoint.""" update_method: str = "update_data" if self.first_call else "update_devices" await _call_api(self.hass, getattr(self.ring_api, update_method)) self.first_call = False - data: dict[str, RingDeviceData] = {} - devices: dict[str : list[RingGeneric]] = self.ring_api.devices() + devices: RingDevices = self.ring_api.devices() subscribed_device_ids = set(self.async_contexts()) - for device_type in devices: - for device in devices[device_type]: - # Don't update all devices in the ring api, only those that set - # their device id as context when they subscribed. - if device.id in subscribed_device_ids: - data[device.id] = RingDeviceData(device=device) - try: - history_task = None - async with TaskGroup() as tg: - if device.has_capability("history"): - history_task = tg.create_task( - _call_api( - self.hass, - lambda device: device.history(limit=10), - device, - msg_suffix=f" for device {device.name}", # device_id is the mac - ) - ) + for device in devices.all_devices: + # Don't update all devices in the ring api, only those that set + # their device id as context when they subscribed. + if device.id in subscribed_device_ids: + try: + async with TaskGroup() as tg: + if device.has_capability("history"): tg.create_task( _call_api( self.hass, - device.update_health_data, - msg_suffix=f" for device {device.name}", + lambda device: device.history(limit=10), + device, + msg_suffix=f" for device {device.name}", # device_id is the mac ) ) - if history_task: - data[device.id].history = history_task.result() - except ExceptionGroup as eg: - raise eg.exceptions[0] # noqa: B904 + tg.create_task( + _call_api( + self.hass, + device.update_health_data, + msg_suffix=f" for device {device.name}", + ) + ) + except ExceptionGroup as eg: + raise eg.exceptions[0] # noqa: B904 - return data + return devices class RingNotificationsCoordinator(DataUpdateCoordinator[None]): @@ -114,6 +102,6 @@ class RingNotificationsCoordinator(DataUpdateCoordinator[None]): ) self.ring_api: Ring = ring_api - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch data from API endpoint.""" await _call_api(self.hass, self.ring_api.update_dings) diff --git a/homeassistant/components/ring/diagnostics.py b/homeassistant/components/ring/diagnostics.py index 5295629979a..2e7604d9f50 100644 --- a/homeassistant/components/ring/diagnostics.py +++ b/homeassistant/components/ring/diagnostics.py @@ -4,12 +4,11 @@ from __future__ import annotations from typing import Any -from ring_doorbell import Ring - from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from . import RingData from .const import DOMAIN TO_REDACT = { @@ -33,11 +32,12 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - ring: Ring = hass.data[DOMAIN][entry.entry_id]["api"] + ring_data: RingData = hass.data[DOMAIN][entry.entry_id] + devices_data = ring_data.api.devices_data devices_raw = [ - ring.devices_data[device_type][device_id] - for device_type in ring.devices_data - for device_id in ring.devices_data[device_type] + devices_data[device_type][device_id] + for device_type in devices_data + for device_id in devices_data[device_type] ] return async_redact_data( {"device_data": devices_raw}, diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index fb617ecd7d1..54f76a19c5d 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -3,7 +3,13 @@ from collections.abc import Callable from typing import Any, Concatenate, ParamSpec, TypeVar -from ring_doorbell import AuthenticationError, RingError, RingGeneric, RingTimeout +from ring_doorbell import ( + AuthenticationError, + RingDevices, + RingError, + RingGeneric, + RingTimeout, +) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -11,26 +17,23 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN -from .coordinator import ( - RingDataCoordinator, - RingDeviceData, - RingNotificationsCoordinator, -) +from .coordinator import RingDataCoordinator, RingNotificationsCoordinator _RingCoordinatorT = TypeVar( "_RingCoordinatorT", bound=(RingDataCoordinator | RingNotificationsCoordinator), ) -_T = TypeVar("_T", bound="RingEntity") +_RingBaseEntityT = TypeVar("_RingBaseEntityT", bound="RingBaseEntity[Any]") +_R = TypeVar("_R") _P = ParamSpec("_P") def exception_wrap( - func: Callable[Concatenate[_T, _P], Any], -) -> Callable[Concatenate[_T, _P], Any]: + func: Callable[Concatenate[_RingBaseEntityT, _P], _R], +) -> Callable[Concatenate[_RingBaseEntityT, _P], _R]: """Define a wrapper to catch exceptions and raise HomeAssistant errors.""" - def _wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + def _wrap(self: _RingBaseEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R: try: return func(self, *args, **kwargs) except AuthenticationError as err: @@ -50,7 +53,7 @@ def exception_wrap( return _wrap -class RingEntity(CoordinatorEntity[_RingCoordinatorT]): +class RingBaseEntity(CoordinatorEntity[_RingCoordinatorT]): """Base implementation for Ring device.""" _attr_attribution = ATTRIBUTION @@ -73,29 +76,16 @@ class RingEntity(CoordinatorEntity[_RingCoordinatorT]): name=device.name, ) - def _get_coordinator_device_data(self) -> RingDeviceData | None: - if (data := self.coordinator.data) and ( - device_data := data.get(self._device.id) - ): - return device_data - return None - def _get_coordinator_device(self) -> RingGeneric | None: - if (device_data := self._get_coordinator_device_data()) and ( - device := device_data.device - ): - return device - return None +class RingEntity(RingBaseEntity[RingDataCoordinator]): + """Implementation for Ring devices.""" - def _get_coordinator_history(self) -> list | None: - if (device_data := self._get_coordinator_device_data()) and ( - history := device_data.history - ): - return history - return None + def _get_coordinator_data(self) -> RingDevices: + return self.coordinator.data @callback def _handle_coordinator_update(self) -> None: - if device := self._get_coordinator_device(): - self._device = device + self._device = self._get_coordinator_data().get_device( + self._device.device_api_id + ) super()._handle_coordinator_update() diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index b9e1c8c38b4..a4eb8df5b46 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -1,6 +1,7 @@ """Component providing HA switch support for Ring Door Bell/Chimes.""" from datetime import timedelta +from enum import StrEnum, auto import logging from typing import Any @@ -12,7 +13,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from . import RingData +from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -26,8 +28,12 @@ _LOGGER = logging.getLogger(__name__) SKIP_UPDATES_DELAY = timedelta(seconds=5) -ON_STATE = "on" -OFF_STATE = "off" + +class OnOffState(StrEnum): + """Enum for allowed on off states.""" + + ON = auto() + OFF = auto() async def async_setup_entry( @@ -36,14 +42,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the lights for the Ring devices.""" - devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] - devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ - RING_DEVICES_COORDINATOR - ] + ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + devices_coordinator = ring_data.devices_coordinator async_add_entities( RingLight(device, devices_coordinator) - for device in devices["stickup_cams"] + for device in ring_data.devices.stickup_cams if device.has_capability("light") ) @@ -55,37 +59,41 @@ class RingLight(RingEntity, LightEntity): _attr_supported_color_modes = {ColorMode.ONOFF} _attr_translation_key = "light" - def __init__(self, device, coordinator): + _device: RingStickUpCam + + def __init__( + self, device: RingStickUpCam, coordinator: RingDataCoordinator + ) -> None: """Initialize the light.""" super().__init__(device, coordinator) self._attr_unique_id = str(device.id) - self._attr_is_on = device.lights == ON_STATE + self._attr_is_on = device.lights == OnOffState.ON self._no_updates_until = dt_util.utcnow() @callback - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: """Call update method.""" if self._no_updates_until > dt_util.utcnow(): return - if (device := self._get_coordinator_device()) and isinstance( - device, RingStickUpCam - ): - self._attr_is_on = device.lights == ON_STATE + device = self._get_coordinator_data().get_stickup_cam( + self._device.device_api_id + ) + self._attr_is_on = device.lights == OnOffState.ON super()._handle_coordinator_update() @exception_wrap - def _set_light(self, new_state): + def _set_light(self, new_state: OnOffState) -> None: """Update light state, and causes Home Assistant to correctly update.""" self._device.lights = new_state - self._attr_is_on = new_state == ON_STATE + self._attr_is_on = new_state == OnOffState.ON self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY self.schedule_update_ha_state() def turn_on(self, **kwargs: Any) -> None: """Turn the light on for 30 seconds.""" - self._set_light(ON_STATE) + self._set_light(OnOffState.ON) def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - self._set_light(OFF_STATE) + self._set_light(OnOffState.OFF) diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 9ba677e7e5b..0c4d1f4fdf5 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -2,10 +2,19 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import Any, Generic, cast -from ring_doorbell import RingGeneric +from ring_doorbell import ( + RingCapability, + RingChime, + RingDoorBell, + RingEventKind, + RingGeneric, + RingOther, +) +from typing_extensions import TypeVar from homeassistant.components.sensor import ( SensorDeviceClass, @@ -21,11 +30,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from . import RingData +from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import RingEntity +_RingDeviceT = TypeVar("_RingDeviceT", bound=RingGeneric, default=RingGeneric) + async def async_setup_entry( hass: HomeAssistant, @@ -33,209 +46,193 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a sensor for a Ring device.""" - devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] - devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ - RING_DEVICES_COORDINATOR - ] + ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + devices_coordinator = ring_data.devices_coordinator entities = [ - description.cls(device, devices_coordinator, description) - for device_type in ( - "chimes", - "doorbots", - "authorized_doorbots", - "stickup_cams", - "other", - ) + RingSensor(device, devices_coordinator, description) for description in SENSOR_TYPES - if device_type in description.category - for device in devices[device_type] - if not (device_type == "battery" and device.battery_life is None) + for device in ring_data.devices.all_devices + if description.exists_fn(device) ] async_add_entities(entities) -class RingSensor(RingEntity, SensorEntity): +class RingSensor(RingEntity, SensorEntity, Generic[_RingDeviceT]): """A sensor implementation for Ring device.""" - entity_description: RingSensorEntityDescription + entity_description: RingSensorEntityDescription[_RingDeviceT] + _device: _RingDeviceT def __init__( self, device: RingGeneric, coordinator: RingDataCoordinator, - description: RingSensorEntityDescription, + description: RingSensorEntityDescription[_RingDeviceT], ) -> None: """Initialize a sensor for Ring device.""" super().__init__(device, coordinator) self.entity_description = description self._attr_unique_id = f"{device.id}-{description.key}" - - @property - def native_value(self): - """Return the state of the sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "volume": - return self._device.volume - if sensor_type == "doorbell_volume": - return self._device.doorbell_volume - if sensor_type == "mic_volume": - return self._device.mic_volume - if sensor_type == "voice_volume": - return self._device.voice_volume - - if sensor_type == "battery": - return self._device.battery_life - - -class HealthDataRingSensor(RingSensor): - """Ring sensor that relies on health data.""" - - # These sensors are data hungry and not useful. Disable by default. - _attr_entity_registry_enabled_default = False - - @property - def native_value(self): - """Return the state of the sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "wifi_signal_category": - return self._device.wifi_signal_category - - if sensor_type == "wifi_signal_strength": - return self._device.wifi_signal_strength - - -class HistoryRingSensor(RingSensor): - """Ring sensor that relies on history data.""" - - _latest_event: dict[str, Any] | None = None + self._attr_entity_registry_enabled_default = ( + description.entity_registry_enabled_default + ) + self._attr_native_value = self.entity_description.value_fn(self._device) @callback - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: """Call update method.""" - if not (history_data := self._get_coordinator_history()): - return - kind = self.entity_description.kind - found = None - if kind is None: - found = history_data[0] - else: - for entry in history_data: - if entry["kind"] == kind: - found = entry - break - - if not found: - return - - self._latest_event = found + self._device = cast( + _RingDeviceT, + self._get_coordinator_data().get_device(self._device.device_api_id), + ) + # History values can drop off the last 10 events so only update + # the value if it's not None + if native_value := self.entity_description.value_fn(self._device): + self._attr_native_value = native_value + if extra_attrs := self.entity_description.extra_state_attributes_fn( + self._device + ): + self._attr_extra_state_attributes = extra_attrs super()._handle_coordinator_update() - @property - def native_value(self): - """Return the state of the sensor.""" - if self._latest_event is None: - return None - return self._latest_event["created_at"] +def _get_last_event( + history_data: list[dict[str, Any]], kind: RingEventKind | None +) -> dict[str, Any] | None: + if not history_data: + return None + if kind is None: + return history_data[0] + for entry in history_data: + if entry["kind"] == kind.value: + return entry + return None - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attrs = super().extra_state_attributes - if self._latest_event: - attrs["created_at"] = self._latest_event["created_at"] - attrs["answered"] = self._latest_event["answered"] - attrs["recording_status"] = self._latest_event["recording"]["status"] - attrs["category"] = self._latest_event["kind"] - - return attrs +def _get_last_event_attrs( + history_data: list[dict[str, Any]], kind: RingEventKind | None +) -> dict[str, Any] | None: + if last_event := _get_last_event(history_data, kind): + return { + "created_at": last_event.get("created_at"), + "answered": last_event.get("answered"), + "recording_status": last_event.get("recording", {}).get("status"), + "category": last_event.get("kind"), + } + return None @dataclass(frozen=True, kw_only=True) -class RingSensorEntityDescription(SensorEntityDescription): +class RingSensorEntityDescription(SensorEntityDescription, Generic[_RingDeviceT]): """Describes Ring sensor entity.""" - category: list[str] - cls: type[RingSensor] - - kind: str | None = None + value_fn: Callable[[_RingDeviceT], StateType] = lambda _: True + exists_fn: Callable[[RingGeneric], bool] = lambda _: True + extra_state_attributes_fn: Callable[[_RingDeviceT], dict[str, Any] | None] = ( + lambda _: None + ) -SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( - RingSensorEntityDescription( +# For some reason mypy doesn't properly type check the default TypeVar value here +# so for now the [RingGeneric] subscript needs to be specified. +# Once https://github.com/python/mypy/issues/14851 is closed this should hopefully +# be fixed and the [RingGeneric] subscript can be removed. +# https://github.com/home-assistant/core/pull/115276#discussion_r1560106576 +SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = ( + RingSensorEntityDescription[RingGeneric]( key="battery", - category=["doorbots", "authorized_doorbots", "stickup_cams", "other"], native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - cls=RingSensor, + value_fn=lambda device: device.battery_life, + exists_fn=lambda device: device.family != "chimes", ), - RingSensorEntityDescription( + RingSensorEntityDescription[RingGeneric]( key="last_activity", translation_key="last_activity", - category=["doorbots", "authorized_doorbots", "stickup_cams", "other"], device_class=SensorDeviceClass.TIMESTAMP, - cls=HistoryRingSensor, + value_fn=lambda device: last_event.get("created_at") + if (last_event := _get_last_event(device.last_history, None)) + else None, + extra_state_attributes_fn=lambda device: last_event_attrs + if (last_event_attrs := _get_last_event_attrs(device.last_history, None)) + else None, + exists_fn=lambda device: device.has_capability(RingCapability.HISTORY), ), - RingSensorEntityDescription( + RingSensorEntityDescription[RingGeneric]( key="last_ding", translation_key="last_ding", - category=["doorbots", "authorized_doorbots", "other"], - kind="ding", device_class=SensorDeviceClass.TIMESTAMP, - cls=HistoryRingSensor, + value_fn=lambda device: last_event.get("created_at") + if (last_event := _get_last_event(device.last_history, RingEventKind.DING)) + else None, + extra_state_attributes_fn=lambda device: last_event_attrs + if ( + last_event_attrs := _get_last_event_attrs( + device.last_history, RingEventKind.DING + ) + ) + else None, + exists_fn=lambda device: device.has_capability(RingCapability.HISTORY), ), - RingSensorEntityDescription( + RingSensorEntityDescription[RingGeneric]( key="last_motion", translation_key="last_motion", - category=["doorbots", "authorized_doorbots", "stickup_cams"], - kind="motion", device_class=SensorDeviceClass.TIMESTAMP, - cls=HistoryRingSensor, + value_fn=lambda device: last_event.get("created_at") + if (last_event := _get_last_event(device.last_history, RingEventKind.MOTION)) + else None, + extra_state_attributes_fn=lambda device: last_event_attrs + if ( + last_event_attrs := _get_last_event_attrs( + device.last_history, RingEventKind.MOTION + ) + ) + else None, + exists_fn=lambda device: device.has_capability(RingCapability.HISTORY), ), - RingSensorEntityDescription( + RingSensorEntityDescription[RingDoorBell | RingChime]( key="volume", translation_key="volume", - category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], - cls=RingSensor, + value_fn=lambda device: device.volume, + exists_fn=lambda device: isinstance(device, (RingDoorBell, RingChime)), ), - RingSensorEntityDescription( + RingSensorEntityDescription[RingOther]( key="doorbell_volume", translation_key="doorbell_volume", - category=["other"], - cls=RingSensor, + value_fn=lambda device: device.doorbell_volume, + exists_fn=lambda device: isinstance(device, RingOther), ), - RingSensorEntityDescription( + RingSensorEntityDescription[RingOther]( key="mic_volume", translation_key="mic_volume", - category=["other"], - cls=RingSensor, + value_fn=lambda device: device.mic_volume, + exists_fn=lambda device: isinstance(device, RingOther), ), - RingSensorEntityDescription( + RingSensorEntityDescription[RingOther]( key="voice_volume", translation_key="voice_volume", - category=["other"], - cls=RingSensor, + value_fn=lambda device: device.voice_volume, + exists_fn=lambda device: isinstance(device, RingOther), ), - RingSensorEntityDescription( + RingSensorEntityDescription[RingGeneric]( key="wifi_signal_category", translation_key="wifi_signal_category", - category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams", "other"], entity_category=EntityCategory.DIAGNOSTIC, - cls=HealthDataRingSensor, + entity_registry_enabled_default=False, + value_fn=lambda device: device.wifi_signal_category, ), - RingSensorEntityDescription( + RingSensorEntityDescription[RingGeneric]( key="wifi_signal_strength", translation_key="wifi_signal_strength", - category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams", "other"], native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, - cls=HealthDataRingSensor, + entity_registry_enabled_default=False, + value_fn=lambda device: device.wifi_signal_strength, ), ) diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 4b7d9243dbf..27f68258bad 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -1,15 +1,17 @@ """Component providing HA Siren support for Ring Chimes.""" import logging +from typing import Any -from ring_doorbell.const import CHIME_TEST_SOUND_KINDS, KIND_DING +from ring_doorbell import RingChime, RingEventKind from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from . import RingData +from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -22,32 +24,33 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the sirens for the Ring devices.""" - devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] - coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ - RING_DEVICES_COORDINATOR - ] + ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + devices_coordinator = ring_data.devices_coordinator async_add_entities( - RingChimeSiren(device, coordinator) for device in devices["chimes"] + RingChimeSiren(device, devices_coordinator) + for device in ring_data.devices.chimes ) class RingChimeSiren(RingEntity, SirenEntity): """Creates a siren to play the test chimes of a Chime device.""" - _attr_available_tones = list(CHIME_TEST_SOUND_KINDS) + _attr_available_tones = [RingEventKind.DING.value, RingEventKind.MOTION.value] _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES _attr_translation_key = "siren" - def __init__(self, device, coordinator: RingDataCoordinator) -> None: + _device: RingChime + + def __init__(self, device: RingChime, coordinator: RingDataCoordinator) -> None: """Initialize a Ring Chime siren.""" super().__init__(device, coordinator) # Entity class attributes self._attr_unique_id = f"{self._device.id}-siren" @exception_wrap - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Play the test sound on a Ring Chime device.""" - tone = kwargs.get(ATTR_TONE) or KIND_DING + tone = kwargs.get(ATTR_TONE) or RingEventKind.DING.value self._device.test_sound(kind=tone) diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 2221eeb7705..b5cd59ac2fb 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -4,7 +4,7 @@ from datetime import timedelta import logging from typing import Any -from ring_doorbell import RingGeneric, RingStickUpCam +from ring_doorbell import RingStickUpCam from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from . import RingData +from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -33,14 +34,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the switches for the Ring devices.""" - devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] - coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ - RING_DEVICES_COORDINATOR - ] + ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + devices_coordinator = ring_data.devices_coordinator async_add_entities( - SirenSwitch(device, coordinator) - for device in devices["stickup_cams"] + SirenSwitch(device, devices_coordinator) + for device in ring_data.devices.stickup_cams if device.has_capability("siren") ) @@ -48,8 +47,10 @@ async def async_setup_entry( class BaseRingSwitch(RingEntity, SwitchEntity): """Represents a switch for controlling an aspect of a ring device.""" + _device: RingStickUpCam + def __init__( - self, device: RingGeneric, coordinator: RingDataCoordinator, device_type: str + self, device: RingStickUpCam, coordinator: RingDataCoordinator, device_type: str ) -> None: """Initialize the switch.""" super().__init__(device, coordinator) @@ -62,26 +63,27 @@ class SirenSwitch(BaseRingSwitch): _attr_translation_key = "siren" - def __init__(self, device, coordinator: RingDataCoordinator) -> None: + def __init__( + self, device: RingStickUpCam, coordinator: RingDataCoordinator + ) -> None: """Initialize the switch for a device with a siren.""" super().__init__(device, coordinator, "siren") self._no_updates_until = dt_util.utcnow() self._attr_is_on = device.siren > 0 @callback - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: """Call update method.""" if self._no_updates_until > dt_util.utcnow(): return - - if (device := self._get_coordinator_device()) and isinstance( - device, RingStickUpCam - ): - self._attr_is_on = device.siren > 0 + device = self._get_coordinator_data().get_stickup_cam( + self._device.device_api_id + ) + self._attr_is_on = device.siren > 0 super()._handle_coordinator_update() @exception_wrap - def _set_switch(self, new_state): + def _set_switch(self, new_state: int) -> None: """Update switch state, and causes Home Assistant to correctly update.""" self._device.siren = new_state diff --git a/mypy.ini b/mypy.ini index 159101a21b3..3e0419be269 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3391,6 +3391,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ring.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.rituals_perfume_genie.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/ring/common.py b/tests/components/ring/common.py index c6852bf87d6..b129623aa95 100644 --- a/tests/components/ring/common.py +++ b/tests/components/ring/common.py @@ -15,4 +15,4 @@ async def setup_platform(hass, platform): ) with patch("homeassistant.components.ring.PLATFORMS", [platform]): assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) From 5308e02c992b5f39aabc4e0252e6af200531294a Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 11 Apr 2024 05:23:10 -0400 Subject: [PATCH 18/64] Add support for adopt data disk repair (#114891) --- homeassistant/components/hassio/repairs.py | 2 +- homeassistant/components/hassio/strings.json | 11 +- tests/components/hassio/test_repairs.py | 113 +++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 8458d7eaac2..63ed3d5c8a3 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -22,7 +22,7 @@ from .const import ( from .handler import async_apply_suggestion from .issues import Issue, Suggestion -SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} +SUGGESTION_CONFIRMATION_REQUIRED = {"system_adopt_data_disk", "system_execute_reboot"} EXTRA_PLACEHOLDERS = { "issue_mount_mount_failed": { diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 77ef408cafe..63c1da4bfd8 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -51,8 +51,15 @@ "title": "Multiple data disks detected", "fix_flow": { "step": { - "system_rename_data_disk": { - "description": "`{reference}` is a filesystem with the name hassos-data and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system." + "fix_menu": { + "description": "`{reference}` is a filesystem with the name hassos-data and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the 'Rename' option to rename the filesystem to prevent this. Use the 'Adopt' option to make that your data disk and rename the existing one. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system.", + "menu_options": { + "system_rename_data_disk": "Rename", + "system_adopt_data_disk": "Adopt" + } + }, + "system_adopt_data_disk": { + "description": "This fix will initiate a system reboot which will make Home Assistant and all the Add-ons inaccessible for a brief period. After the reboot `{reference}` will be the data disk of Home Assistant and your existing data disk will be renamed and ignored." } }, "abort": { diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index d387968da46..2dffba74fef 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -674,3 +674,116 @@ async def test_supervisor_issue_docker_config_repair_flow( str(aioclient_mock.mock_calls[-1][1]) == "http://127.0.0.1/resolution/suggestion/1235" ) + + +async def test_supervisor_issue_repair_flow_multiple_data_disks( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, +) -> None: + """Test fix flow for multiple data disks supervisor issue.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "multiple_data_disks", + "context": "system", + "reference": "/dev/sda1", + "suggestions": [ + { + "uuid": "1235", + "type": "rename_data_disk", + "context": "system", + "reference": "/dev/sda1", + }, + { + "uuid": "1236", + "type": "adopt_data_disk", + "context": "system", + "reference": "/dev/sda1", + }, + ], + }, + ], + ) + + assert await async_setup_component(hass, "hassio", {}) + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "menu", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "fix_menu", + "data_schema": [ + { + "type": "select", + "options": [ + ["system_rename_data_disk", "system_rename_data_disk"], + ["system_adopt_data_disk", "system_adopt_data_disk"], + ], + "name": "next_step_id", + } + ], + "menu_options": ["system_rename_data_disk", "system_adopt_data_disk"], + "description_placeholders": {"reference": "/dev/sda1"}, + } + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "system_adopt_data_disk"}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "system_adopt_data_disk", + "data_schema": [], + "errors": None, + "description_placeholders": {"reference": "/dev/sda1"}, + "last_step": True, + "preview": None, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1236" + ) From df5d818c0896d9011cc17a2ab9c757c8f3e9b759 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Apr 2024 10:31:37 +0100 Subject: [PATCH 19/64] Make ring device generic in RingEntity (#115406) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/components/ring/button.py | 4 +--- homeassistant/components/ring/camera.py | 3 +-- homeassistant/components/ring/entity.py | 20 +++++++++++++------- homeassistant/components/ring/light.py | 4 +--- homeassistant/components/ring/sensor.py | 22 +++++++++------------- homeassistant/components/ring/siren.py | 4 +--- homeassistant/components/ring/switch.py | 4 +--- 7 files changed, 27 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py index a14853a0881..15d56a8b7cf 100644 --- a/homeassistant/components/ring/button.py +++ b/homeassistant/components/ring/button.py @@ -35,11 +35,9 @@ async def async_setup_entry( ) -class RingDoorButton(RingEntity, ButtonEntity): +class RingDoorButton(RingEntity[RingOther], ButtonEntity): """Creates a button to open the ring intercom door.""" - _device: RingOther - def __init__( self, device: RingOther, diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 297e5f47627..282f9816c4c 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -48,11 +48,10 @@ async def async_setup_entry( async_add_entities(cams) -class RingCam(RingEntity, Camera): +class RingCam(RingEntity[RingDoorBell], Camera): """An implementation of a Ring Door Bell camera.""" _attr_name = None - _device: RingDoorBell def __init__( self, diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 54f76a19c5d..65ccbb8ece4 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -1,7 +1,7 @@ """Base class for Ring entity.""" from collections.abc import Callable -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate, Generic, ParamSpec, cast from ring_doorbell import ( AuthenticationError, @@ -10,6 +10,7 @@ from ring_doorbell import ( RingGeneric, RingTimeout, ) +from typing_extensions import TypeVar from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -19,11 +20,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN from .coordinator import RingDataCoordinator, RingNotificationsCoordinator +RingDeviceT = TypeVar("RingDeviceT", bound=RingGeneric, default=RingGeneric) + _RingCoordinatorT = TypeVar( "_RingCoordinatorT", bound=(RingDataCoordinator | RingNotificationsCoordinator), ) -_RingBaseEntityT = TypeVar("_RingBaseEntityT", bound="RingBaseEntity[Any]") +_RingBaseEntityT = TypeVar("_RingBaseEntityT", bound="RingBaseEntity[Any, Any]") _R = TypeVar("_R") _P = ParamSpec("_P") @@ -53,7 +56,9 @@ def exception_wrap( return _wrap -class RingBaseEntity(CoordinatorEntity[_RingCoordinatorT]): +class RingBaseEntity( + CoordinatorEntity[_RingCoordinatorT], Generic[_RingCoordinatorT, RingDeviceT] +): """Base implementation for Ring device.""" _attr_attribution = ATTRIBUTION @@ -62,7 +67,7 @@ class RingBaseEntity(CoordinatorEntity[_RingCoordinatorT]): def __init__( self, - device: RingGeneric, + device: RingDeviceT, coordinator: _RingCoordinatorT, ) -> None: """Initialize a sensor for Ring device.""" @@ -77,7 +82,7 @@ class RingBaseEntity(CoordinatorEntity[_RingCoordinatorT]): ) -class RingEntity(RingBaseEntity[RingDataCoordinator]): +class RingEntity(RingBaseEntity[RingDataCoordinator, RingDeviceT]): """Implementation for Ring devices.""" def _get_coordinator_data(self) -> RingDevices: @@ -85,7 +90,8 @@ class RingEntity(RingBaseEntity[RingDataCoordinator]): @callback def _handle_coordinator_update(self) -> None: - self._device = self._get_coordinator_data().get_device( - self._device.device_api_id + self._device = cast( + RingDeviceT, + self._get_coordinator_data().get_device(self._device.device_api_id), ) super()._handle_coordinator_update() diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index a4eb8df5b46..5747c9e77f7 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -52,15 +52,13 @@ async def async_setup_entry( ) -class RingLight(RingEntity, LightEntity): +class RingLight(RingEntity[RingStickUpCam], LightEntity): """Creates a switch to turn the ring cameras light on and off.""" _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} _attr_translation_key = "light" - _device: RingStickUpCam - def __init__( self, device: RingStickUpCam, coordinator: RingDataCoordinator ) -> None: diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 0c4d1f4fdf5..b6849e37d96 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -14,7 +14,6 @@ from ring_doorbell import ( RingGeneric, RingOther, ) -from typing_extensions import TypeVar from homeassistant.components.sensor import ( SensorDeviceClass, @@ -35,9 +34,7 @@ from homeassistant.helpers.typing import StateType from . import RingData from .const import DOMAIN from .coordinator import RingDataCoordinator -from .entity import RingEntity - -_RingDeviceT = TypeVar("_RingDeviceT", bound=RingGeneric, default=RingGeneric) +from .entity import RingDeviceT, RingEntity async def async_setup_entry( @@ -59,17 +56,16 @@ async def async_setup_entry( async_add_entities(entities) -class RingSensor(RingEntity, SensorEntity, Generic[_RingDeviceT]): +class RingSensor(RingEntity[RingDeviceT], SensorEntity): """A sensor implementation for Ring device.""" - entity_description: RingSensorEntityDescription[_RingDeviceT] - _device: _RingDeviceT + entity_description: RingSensorEntityDescription[RingDeviceT] def __init__( self, - device: RingGeneric, + device: RingDeviceT, coordinator: RingDataCoordinator, - description: RingSensorEntityDescription[_RingDeviceT], + description: RingSensorEntityDescription[RingDeviceT], ) -> None: """Initialize a sensor for Ring device.""" super().__init__(device, coordinator) @@ -85,7 +81,7 @@ class RingSensor(RingEntity, SensorEntity, Generic[_RingDeviceT]): """Call update method.""" self._device = cast( - _RingDeviceT, + RingDeviceT, self._get_coordinator_data().get_device(self._device.device_api_id), ) # History values can drop off the last 10 events so only update @@ -126,12 +122,12 @@ def _get_last_event_attrs( @dataclass(frozen=True, kw_only=True) -class RingSensorEntityDescription(SensorEntityDescription, Generic[_RingDeviceT]): +class RingSensorEntityDescription(SensorEntityDescription, Generic[RingDeviceT]): """Describes Ring sensor entity.""" - value_fn: Callable[[_RingDeviceT], StateType] = lambda _: True + value_fn: Callable[[RingDeviceT], StateType] = lambda _: True exists_fn: Callable[[RingGeneric], bool] = lambda _: True - extra_state_attributes_fn: Callable[[_RingDeviceT], dict[str, Any] | None] = ( + extra_state_attributes_fn: Callable[[RingDeviceT], dict[str, Any] | None] = ( lambda _: None ) diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 27f68258bad..f63f9d33182 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -33,15 +33,13 @@ async def async_setup_entry( ) -class RingChimeSiren(RingEntity, SirenEntity): +class RingChimeSiren(RingEntity[RingChime], SirenEntity): """Creates a siren to play the test chimes of a Chime device.""" _attr_available_tones = [RingEventKind.DING.value, RingEventKind.MOTION.value] _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES _attr_translation_key = "siren" - _device: RingChime - def __init__(self, device: RingChime, coordinator: RingDataCoordinator) -> None: """Initialize a Ring Chime siren.""" super().__init__(device, coordinator) diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index b5cd59ac2fb..0e032907bae 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -44,11 +44,9 @@ async def async_setup_entry( ) -class BaseRingSwitch(RingEntity, SwitchEntity): +class BaseRingSwitch(RingEntity[RingStickUpCam], SwitchEntity): """Represents a switch for controlling an aspect of a ring device.""" - _device: RingStickUpCam - def __init__( self, device: RingStickUpCam, coordinator: RingDataCoordinator, device_type: str ) -> None: From 10076e652353ac2959babb32fb790ac0630b218f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 11 Apr 2024 12:04:08 +0200 Subject: [PATCH 20/64] Add notify entity component (#110950) * Add notify entity component * Device classes, restore state, icons * Add icons file * Add tests for kitchen_sink * Remove notify from no_entity_platforms in hassfest icons, translation link * ruff * Remove `data` feature * Only message support * Complete initial device classes * mypy pylint * Remove device_class implementation * format * Follow up comments * Remove _attr_supported_features * Use setup_test_component_platform * User helper at other places * last comment * Add entry unload test and non async test * Avoid default mutable object in constructor --- .../components/kitchen_sink/__init__.py | 3 +- .../components/kitchen_sink/notify.py | 54 ++++ homeassistant/components/notify/__init__.py | 92 ++++++- homeassistant/components/notify/const.py | 6 +- homeassistant/components/notify/icons.json | 8 +- homeassistant/components/notify/services.yaml | 10 + homeassistant/components/notify/strings.json | 15 ++ homeassistant/helpers/service.py | 2 + tests/components/kitchen_sink/test_notify.py | 66 +++++ tests/components/notify/conftest.py | 23 ++ tests/components/notify/test_init.py | 239 ++++++++++++++++-- 11 files changed, 493 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/kitchen_sink/notify.py create mode 100644 tests/components/kitchen_sink/test_notify.py create mode 100644 tests/components/notify/conftest.py diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 6b6694c920d..94dfca77410 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -32,6 +32,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.IMAGE, Platform.LAWN_MOWER, Platform.LOCK, + Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH, Platform.WEATHER, @@ -70,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -def _create_issues(hass): +def _create_issues(hass: HomeAssistant) -> None: """Create some issue registry issues.""" async_create_issue( hass, diff --git a/homeassistant/components/kitchen_sink/notify.py b/homeassistant/components/kitchen_sink/notify.py new file mode 100644 index 00000000000..b0418411145 --- /dev/null +++ b/homeassistant/components/kitchen_sink/notify.py @@ -0,0 +1,54 @@ +"""Demo platform that offers a fake notify entity.""" + +from __future__ import annotations + +from homeassistant.components import persistent_notification +from homeassistant.components.notify import NotifyEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the demo notify entity platform.""" + async_add_entities( + [ + DemoNotify( + unique_id="just_notify_me", + device_name="MyBox", + entity_name="Personal notifier", + ), + ] + ) + + +class DemoNotify(NotifyEntity): + """Representation of a demo notify entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + device_name: str, + entity_name: str | None, + ) -> None: + """Initialize the Demo button entity.""" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) + self._attr_name = entity_name + + async def async_send_message(self, message: str) -> None: + """Send out a persistent notification.""" + persistent_notification.async_create(self.hass, message, "Demo notification") diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index e7390a49676..81b7d300acc 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -2,24 +2,36 @@ from __future__ import annotations +from datetime import timedelta +from functools import cached_property, partial +import logging +from typing import Any, final, override + import voluptuous as vol import homeassistant.components.persistent_notification as pn -from homeassistant.const import CONF_NAME, CONF_PLATFORM +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util from .const import ( # noqa: F401 ATTR_DATA, ATTR_MESSAGE, + ATTR_RECIPIENTS, ATTR_TARGET, ATTR_TITLE, DOMAIN, NOTIFY_SERVICE_SCHEMA, SERVICE_NOTIFY, SERVICE_PERSISTENT_NOTIFICATION, + SERVICE_SEND_MESSAGE, ) from .legacy import ( # noqa: F401 BaseNotificationService, @@ -29,9 +41,17 @@ from .legacy import ( # noqa: F401 check_templates_warn, ) +# mypy: disallow-any-generics + # Platform specific data ATTR_TITLE_DEFAULT = "Home Assistant" +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +_LOGGER = logging.getLogger(__name__) + PLATFORM_SCHEMA = vol.Schema( {vol.Required(CONF_PLATFORM): cv.string, vol.Optional(CONF_NAME): cv.string}, extra=vol.ALLOW_EXTRA, @@ -50,6 +70,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # legacy platforms to finish setting up. hass.async_create_task(setup, eager_start=True) + component = hass.data[DOMAIN] = EntityComponent[NotifyEntity](_LOGGER, DOMAIN, hass) + component.async_register_entity_service( + SERVICE_SEND_MESSAGE, + {vol.Required(ATTR_MESSAGE): cv.string}, + "_async_send_message", + ) + async def persistent_notification(service: ServiceCall) -> None: """Send notification via the built-in persistent_notify integration.""" message: Template = service.data[ATTR_MESSAGE] @@ -79,3 +106,66 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) return True + + +class NotifyEntityDescription(EntityDescription, frozen_or_thawed=True): + """A class that describes button entities.""" + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[NotifyEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[NotifyEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +class NotifyEntity(RestoreEntity): + """Representation of a notify entity.""" + + entity_description: NotifyEntityDescription + _attr_should_poll = False + _attr_device_class: None + _attr_state: None = None + __last_notified_isoformat: str | None = None + + @cached_property + @final + @override + def state(self) -> str | None: + """Return the entity state.""" + return self.__last_notified_isoformat + + def __set_state(self, state: str | None) -> None: + """Invalidate the cache of the cached property.""" + self.__dict__.pop("state", None) + self.__last_notified_isoformat = state + + async def async_internal_added_to_hass(self) -> None: + """Call when the notify entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if state is not None and state.state not in (STATE_UNAVAILABLE, None): + self.__set_state(state.state) + + @final + async def _async_send_message(self, **kwargs: Any) -> None: + """Send a notification message (from e.g., service call). + + Should not be overridden, handle setting last notification timestamp. + """ + self.__set_state(dt_util.utcnow().isoformat()) + self.async_write_ha_state() + await self.async_send_message(**kwargs) + + def send_message(self, message: str) -> None: + """Send a message.""" + raise NotImplementedError + + async def async_send_message(self, message: str) -> None: + """Send a message.""" + await self.hass.async_add_executor_job(partial(self.send_message, message)) diff --git a/homeassistant/components/notify/const.py b/homeassistant/components/notify/const.py index b653b5d1cbf..6cd957e3afe 100644 --- a/homeassistant/components/notify/const.py +++ b/homeassistant/components/notify/const.py @@ -11,9 +11,12 @@ ATTR_DATA = "data" # Text to notify user of ATTR_MESSAGE = "message" -# Target of the notification (user, device, etc) +# Target of the (legacy) notification (user, device, etc) ATTR_TARGET = "target" +# Recipients for a notification +ATTR_RECIPIENTS = "recipients" + # Title of notification ATTR_TITLE = "title" @@ -22,6 +25,7 @@ DOMAIN = "notify" LOGGER = logging.getLogger(__package__) SERVICE_NOTIFY = "notify" +SERVICE_SEND_MESSAGE = "send_message" SERVICE_PERSISTENT_NOTIFICATION = "persistent_notification" NOTIFY_SERVICE_SCHEMA = vol.Schema( diff --git a/homeassistant/components/notify/icons.json b/homeassistant/components/notify/icons.json index 88577bc2356..ace8ee0c96b 100644 --- a/homeassistant/components/notify/icons.json +++ b/homeassistant/components/notify/icons.json @@ -1,6 +1,12 @@ { + "entity_component": { + "_": { + "default": "mdi:message" + } + }, "services": { "notify": "mdi:bell-ring", - "persistent_notification": "mdi:bell-badge" + "persistent_notification": "mdi:bell-badge", + "send_message": "mdi:message-arrow-right" } } diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml index 8d053e3af58..ae2a0254761 100644 --- a/homeassistant/components/notify/services.yaml +++ b/homeassistant/components/notify/services.yaml @@ -20,6 +20,16 @@ notify: selector: object: +send_message: + target: + entity: + domain: notify + fields: + message: + required: true + selector: + text: + persistent_notification: fields: message: diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json index cff7b265c37..b0dca501509 100644 --- a/homeassistant/components/notify/strings.json +++ b/homeassistant/components/notify/strings.json @@ -1,5 +1,10 @@ { "title": "Notifications", + "entity_component": { + "_": { + "name": "[%key:component::notify::title%]" + } + }, "services": { "notify": { "name": "Send a notification", @@ -23,6 +28,16 @@ } } }, + "send_message": { + "name": "Send a notification message", + "description": "Sends a notification message.", + "fields": { + "message": { + "name": "Message", + "description": "Your notification message." + } + } + }, "persistent_notification": { "name": "Send a persistent notification", "description": "Sends a notification that is visible in the **Notifications** panel.", diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9af02402bc0..31e0d3648db 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -93,6 +93,7 @@ def _base_components() -> dict[str, ModuleType]: light, lock, media_player, + notify, remote, siren, todo, @@ -112,6 +113,7 @@ def _base_components() -> dict[str, ModuleType]: "light": light, "lock": lock, "media_player": media_player, + "notify": notify, "remote": remote, "siren": siren, "todo": todo, diff --git a/tests/components/kitchen_sink/test_notify.py b/tests/components/kitchen_sink/test_notify.py new file mode 100644 index 00000000000..6d02bacb7be --- /dev/null +++ b/tests/components/kitchen_sink/test_notify.py @@ -0,0 +1,66 @@ +"""The tests for the demo button component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.components.notify.const import ATTR_MESSAGE +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +ENTITY_DIRECT_MESSAGE = "notify.mybox_personal_notifier" + + +@pytest.fixture +async def notify_only() -> AsyncGenerator[None, None]: + """Enable only the button platform.""" + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [Platform.NOTIFY], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, notify_only: None): + """Set up demo component.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get(ENTITY_DIRECT_MESSAGE) + assert state + assert state.state == STATE_UNKNOWN + + +async def test_send_message( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test pressing the button.""" + state = hass.states.get(ENTITY_DIRECT_MESSAGE) + assert state + assert state.state == STATE_UNKNOWN + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + freezer.move_to(now) + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + {ATTR_ENTITY_ID: ENTITY_DIRECT_MESSAGE, ATTR_MESSAGE: "You have an update!"}, + blocking=True, + ) + + state = hass.states.get(ENTITY_DIRECT_MESSAGE) + assert state + assert state.state == now.isoformat() diff --git a/tests/components/notify/conftest.py b/tests/components/notify/conftest.py new file mode 100644 index 00000000000..23930132f7b --- /dev/null +++ b/tests/components/notify/conftest.py @@ -0,0 +1,23 @@ +"""Fixtures for Notify platform tests.""" + +from collections.abc import Generator + +import pytest + +from homeassistant.config_entries import ConfigFlow +from homeassistant.core import HomeAssistant + +from tests.common import mock_config_flow, mock_platform + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, "test.config_flow") + + with mock_config_flow("test", MockFlow): + yield diff --git a/tests/components/notify/test_init.py b/tests/components/notify/test_init.py index 0b75a3c4691..26ed2ddc250 100644 --- a/tests/components/notify/test_init.py +++ b/tests/components/notify/test_init.py @@ -1,28 +1,216 @@ """The tests for notify services that change targets.""" import asyncio +import copy from pathlib import Path -from unittest.mock import Mock, patch +from typing import Any +from unittest.mock import MagicMock, Mock, patch import pytest import yaml from homeassistant import config as hass_config from homeassistant.components import notify -from homeassistant.const import SERVICE_RELOAD, Platform -from homeassistant.core import HomeAssistant +from homeassistant.components.notify import ( + DOMAIN, + SERVICE_SEND_MESSAGE, + NotifyEntity, + NotifyEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SERVICE_RELOAD, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant, State from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.setup import async_setup_component -from tests.common import MockPlatform, async_get_persistent_notifications, mock_platform +from tests.common import ( + MockConfigEntry, + MockEntity, + MockModule, + MockPlatform, + async_get_persistent_notifications, + mock_integration, + mock_platform, + mock_restore_cache, + setup_test_component_platform, +) + +TEST_KWARGS = {"message": "Test message"} + + +class MockNotifyEntity(MockEntity, NotifyEntity): + """Mock Email notitier entity to use in tests.""" + + send_message_mock_calls = MagicMock() + + async def async_send_message(self, message: str) -> None: + """Send a notification message.""" + self.send_message_mock_calls(message=message) + + +class MockNotifyEntityNonAsync(MockEntity, NotifyEntity): + """Mock Email notitier entity to use in tests.""" + + send_message_mock_calls = MagicMock() + + def send_message(self, message: str) -> None: + """Send a notification message.""" + self.send_message_mock_calls(message=message) + + +async def help_async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + +async def help_async_unload_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Unload test config emntry.""" + return await hass.config_entries.async_unload_platforms( + config_entry, [Platform.NOTIFY] + ) + + +@pytest.mark.parametrize( + "entity", + [ + MockNotifyEntityNonAsync(name="test", entity_id="notify.test"), + MockNotifyEntity(name="test", entity_id="notify.test"), + ], + ids=["non_async", "async"], +) +async def test_send_message_service( + hass: HomeAssistant, config_flow_fixture: None, entity: NotifyEntity +) -> None: + """Test send_message service.""" + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get("notify.test") + assert state.state is STATE_UNKNOWN + + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + copy.deepcopy(TEST_KWARGS) | {"entity_id": "notify.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + entity.send_message_mock_calls.assert_called_once() + + # Test unloading the entry succeeds + assert await hass.config_entries.async_unload(config_entry.entry_id) + + +@pytest.mark.parametrize( + ("state", "init_state"), + [ + ("2021-01-01T23:59:59+00:00", "2021-01-01T23:59:59+00:00"), + (STATE_UNAVAILABLE, STATE_UNKNOWN), + ], +) +async def test_restore_state( + hass: HomeAssistant, config_flow_fixture: None, state: str, init_state: str +) -> None: + """Test we restore state integration.""" + mock_restore_cache(hass, (State("notify.test", state),)) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + ), + ) + + entity = MockNotifyEntity(name="test", entity_id="notify.test") + setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get("notify.test") + assert state is not None + assert state.state is init_state + + +async def test_name(hass: HomeAssistant, config_flow_fixture: None) -> None: + """Test notify name.""" + + mock_platform(hass, "test.config_flow") + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + ), + ) + + # Unnamed notify entity -> no name + entity1 = NotifyEntity() + entity1.entity_id = "notify.test1" + + # Unnamed notify entity and has_entity_name True -> unnamed + entity2 = NotifyEntity() + entity2.entity_id = "notify.test3" + entity2._attr_has_entity_name = True + + # Named notify entity and has_entity_name True -> named + entity3 = NotifyEntity() + entity3.entity_id = "notify.test4" + entity3.entity_description = NotifyEntityDescription("test", has_entity_name=True) + + setup_test_component_platform( + hass, DOMAIN, [entity1, entity2, entity3], from_config_entry=True + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity1.entity_id) + assert state + assert state.attributes == {} + + state = hass.states.get(entity2.entity_id) + assert state + assert state.attributes == {} + + state = hass.states.get(entity3.entity_id) + assert state + assert state.attributes == {} class MockNotifyPlatform(MockPlatform): - """Help to set up test notify service.""" + """Help to set up a legacy test notify service.""" - def __init__(self, async_get_service=None, get_service=None): - """Return the notify service.""" + def __init__(self, async_get_service: Any = None, get_service: Any = None) -> None: + """Return a legacy notify service.""" super().__init__() if get_service: self.get_service = get_service @@ -31,9 +219,13 @@ class MockNotifyPlatform(MockPlatform): def mock_notify_platform( - hass, tmp_path, integration="notify", async_get_service=None, get_service=None + hass: HomeAssistant, + tmp_path: Path, + integration: str = "notify", + async_get_service: Any = None, + get_service: Any = None, ): - """Specialize the mock platform for notify.""" + """Specialize the mock platform for legacy notify service.""" loaded_platform = MockNotifyPlatform(async_get_service, get_service) mock_platform(hass, f"{integration}.notify", loaded_platform) @@ -41,7 +233,7 @@ def mock_notify_platform( async def test_same_targets(hass: HomeAssistant) -> None: - """Test not changing the targets in a notify service.""" + """Test not changing the targets in a legacy notify service.""" test = NotificationService(hass) await test.async_setup(hass, "notify", "test") await test.async_register_services() @@ -56,7 +248,7 @@ async def test_same_targets(hass: HomeAssistant) -> None: async def test_change_targets(hass: HomeAssistant) -> None: - """Test changing the targets in a notify service.""" + """Test changing the targets in a legacy notify service.""" test = NotificationService(hass) await test.async_setup(hass, "notify", "test") await test.async_register_services() @@ -73,7 +265,7 @@ async def test_change_targets(hass: HomeAssistant) -> None: async def test_add_targets(hass: HomeAssistant) -> None: - """Test adding the targets in a notify service.""" + """Test adding the targets in a legacy notify service.""" test = NotificationService(hass) await test.async_setup(hass, "notify", "test") await test.async_register_services() @@ -90,7 +282,7 @@ async def test_add_targets(hass: HomeAssistant) -> None: async def test_remove_targets(hass: HomeAssistant) -> None: - """Test removing targets from the targets in a notify service.""" + """Test removing targets from the targets in a legacy notify service.""" test = NotificationService(hass) await test.async_setup(hass, "notify", "test") await test.async_register_services() @@ -107,17 +299,22 @@ async def test_remove_targets(hass: HomeAssistant) -> None: class NotificationService(notify.BaseNotificationService): - """A test class for notification services.""" + """A test class for legacy notification services.""" - def __init__(self, hass, target_list={"a": 1, "b": 2}, name="notify"): + def __init__( + self, + hass: HomeAssistant, + target_list: dict[str, Any] | None = None, + name="notify", + ) -> None: """Initialize the service.""" - async def _async_make_reloadable(hass): + async def _async_make_reloadable(hass: HomeAssistant) -> None: """Initialize the reload service.""" await async_setup_reload_service(hass, name, [notify.DOMAIN]) self.hass = hass - self.target_list = target_list + self.target_list = target_list or {"a": 1, "b": 2} hass.async_create_task(_async_make_reloadable(hass)) @property @@ -229,7 +426,7 @@ async def test_platform_setup_with_error( async def test_reload_with_notify_builtin_platform_reload( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: - """Test reload using the notify platform reload method.""" + """Test reload using the legacy notify platform reload method.""" async def async_get_service(hass, config, discovery_info=None): """Get notify service for mocked platform.""" @@ -271,7 +468,7 @@ async def test_setup_platform_and_reload( return NotificationService(hass, targetlist, "testnotify") async def async_get_service2(hass, config, discovery_info=None): - """Get notify service for mocked platform.""" + """Get legacy notify service for mocked platform.""" get_service_called(config, discovery_info) targetlist = {"c": 3, "d": 4} return NotificationService(hass, targetlist, "testnotify2") @@ -351,7 +548,7 @@ async def test_setup_platform_and_reload( async def test_setup_platform_before_notify_setup( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: - """Test trying to setup a platform before notify is setup.""" + """Test trying to setup a platform before legacy notify service is setup.""" get_service_called = Mock() async def async_get_service(hass, config, discovery_info=None): @@ -401,7 +598,7 @@ async def test_setup_platform_before_notify_setup( async def test_setup_platform_after_notify_setup( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: - """Test trying to setup a platform after notify is setup.""" + """Test trying to setup a platform after legacy notify service is set up.""" get_service_called = Mock() async def async_get_service(hass, config, discovery_info=None): From f558121752dbf6cc9caa6c1f3f6f722209f98b6f Mon Sep 17 00:00:00 2001 From: Jessica Smith <8505845+NodeJSmith@users.noreply.github.com> Date: Thu, 11 Apr 2024 09:26:05 -0500 Subject: [PATCH 21/64] Bump whirlpool-sixth-sense to 0.18.8 (#115393) bump whirlpool to 0.18.8 --- homeassistant/components/whirlpool/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index ee7861588ed..5618a3f61cb 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.7"] + "requirements": ["whirlpool-sixth-sense==0.18.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 773df97bfba..70dc3f56091 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2857,7 +2857,7 @@ webmin-xmlrpc==0.0.2 webrtc-noise-gain==1.2.3 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.7 +whirlpool-sixth-sense==0.18.8 # homeassistant.components.whois whois==0.9.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9428dcd42ca..08031fbe2d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2207,7 +2207,7 @@ webmin-xmlrpc==0.0.2 webrtc-noise-gain==1.2.3 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.7 +whirlpool-sixth-sense==0.18.8 # homeassistant.components.whois whois==0.9.27 From 6c1bc2a9f4d734094d23d41c47e2ee36d659eccd Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 11 Apr 2024 09:13:39 -0700 Subject: [PATCH 22/64] Reduce scope of diagnostics tests for rtsp_to_webrtc to not depend on global state (#115422) --- tests/components/rtsp_to_webrtc/test_diagnostics.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/components/rtsp_to_webrtc/test_diagnostics.py b/tests/components/rtsp_to_webrtc/test_diagnostics.py index 8af6b914191..ad3522686b6 100644 --- a/tests/components/rtsp_to_webrtc/test_diagnostics.py +++ b/tests/components/rtsp_to_webrtc/test_diagnostics.py @@ -2,8 +2,6 @@ from typing import Any -from freezegun.api import FrozenDateTimeFactory - from homeassistant.core import HomeAssistant from .conftest import ComponentSetup @@ -21,13 +19,9 @@ async def test_entry_diagnostics( config_entry: MockConfigEntry, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup, - freezer: FrozenDateTimeFactory, ) -> None: """Test config entry diagnostics.""" await setup_integration() - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "discovery": {"attempt": 1, "web.failure": 1, "webrtc.success": 1}, - "web": {}, - "webrtc": {}, - } + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert "webrtc" in result From 6ba7a30cc80d36907ddb840c2f9093129ff3cb8a Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 11 Apr 2024 21:51:09 +0200 Subject: [PATCH 23/64] Fix Codecov upload with token (#115384) Co-authored-by: Martin Hjelmare Co-authored-by: J. Nick Koston --- .github/workflows/ci.yaml | 44 +++++++++++++-------------------------- 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e1281a14b5c..4299f298122 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1088,25 +1088,17 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov (full coverage) if: needs.info.outputs.test_full_suite == 'true' - uses: Wandalen/wretry.action@v3.1.0 + uses: codecov/codecov-action@v4.3.0 with: - action: codecov/codecov-action@v4.3.0 - with: | - fail_ci_if_error: true - flags: full-suite - token: ${{ env.CODECOV_TOKEN }} - attempt_limit: 5 - attempt_delay: 30000 + fail_ci_if_error: true + flags: full-suite + token: ${{ secrets.CODECOV_TOKEN }} - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' - uses: Wandalen/wretry.action@v3.1.0 + uses: codecov/codecov-action@v4.3.0 with: - action: codecov/codecov-action@v4.3.0 - with: | - fail_ci_if_error: true - token: ${{ env.CODECOV_TOKEN }} - attempt_limit: 5 - attempt_delay: 30000 + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} pytest-partial: runs-on: ubuntu-22.04 @@ -1234,22 +1226,14 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov (full coverage) if: needs.info.outputs.test_full_suite == 'true' - uses: Wandalen/wretry.action@v3.1.0 + uses: codecov/codecov-action@v4.3.0 with: - action: codecov/codecov-action@v4.3.0 - with: | - fail_ci_if_error: true - flags: full-suite - token: ${{ env.CODECOV_TOKEN }} - attempt_limit: 5 - attempt_delay: 30000 + fail_ci_if_error: true + flags: full-suite + token: ${{ secrets.CODECOV_TOKEN }} - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' - uses: Wandalen/wretry.action@v3.1.0 + uses: codecov/codecov-action@v4.3.0 with: - action: codecov/codecov-action@v4.3.0 - with: | - fail_ci_if_error: true - token: ${{ env.CODECOV_TOKEN }} - attempt_limit: 5 - attempt_delay: 30000 + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} From c14f11fbf0068686e99f6be20f68a871b6983b3a Mon Sep 17 00:00:00 2001 From: Santobert Date: Thu, 11 Apr 2024 21:57:18 +0200 Subject: [PATCH 24/64] Bump pybotvac to 0.0.25 (#115435) Bump pybotvac --- homeassistant/components/neato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index 1d5edb7ca44..d6eff486b05 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/neato", "iot_class": "cloud_polling", "loggers": ["pybotvac"], - "requirements": ["pybotvac==0.0.24"] + "requirements": ["pybotvac==0.0.25"] } diff --git a/requirements_all.txt b/requirements_all.txt index 70dc3f56091..76886fa4b96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1722,7 +1722,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.neato -pybotvac==0.0.24 +pybotvac==0.0.25 # homeassistant.components.braviatv pybravia==0.3.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08031fbe2d5..bba41cc4d8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1357,7 +1357,7 @@ pybalboa==1.0.1 pyblackbird==0.6 # homeassistant.components.neato -pybotvac==0.0.24 +pybotvac==0.0.25 # homeassistant.components.braviatv pybravia==0.3.3 From d9fc9f2e0c8020fe745427d60a5ce17f199217ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Apr 2024 11:22:50 -1000 Subject: [PATCH 25/64] Convert async_setup calls for auth sub-modules to callback functions (#115443) --- homeassistant/components/auth/__init__.py | 4 ++-- homeassistant/components/auth/login_flow.py | 5 +++-- homeassistant/components/auth/mfa_setup_flow.py | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index d0e605e7c1e..ff54971eb64 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -195,8 +195,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_delete_all_refresh_tokens) websocket_api.async_register_command(hass, websocket_sign_path) - await login_flow.async_setup(hass, store_result) - await mfa_setup_flow.async_setup(hass) + login_flow.async_setup(hass, store_result) + mfa_setup_flow.async_setup(hass) return True diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 6c33d270f5f..5bad0dbb999 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -91,7 +91,7 @@ from homeassistant.components.http.ban import ( ) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.network import is_cloud_connection from homeassistant.util.network import is_local @@ -105,7 +105,8 @@ if TYPE_CHECKING: from . import StoreResultType -async def async_setup( +@callback +def async_setup( hass: HomeAssistant, store_result: Callable[[str, Credentials], str] ) -> None: """Component to allow users to login.""" diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index aaa1dbaedbf..35d87cafd4f 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -62,7 +62,8 @@ class MfaFlowManager(data_entry_flow.FlowManager): return result -async def async_setup(hass: HomeAssistant) -> None: +@callback +def async_setup(hass: HomeAssistant) -> None: """Init mfa setup flow manager.""" hass.data[DATA_SETUP_FLOW_MGR] = MfaFlowManager(hass) From db38da8eb8ef3e3e4b5afcad39fc0f33485236fd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 11 Apr 2024 23:35:15 +0200 Subject: [PATCH 26/64] Update pytest warnings filter (#115275) --- pyproject.toml | 62 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 66c82d2e770..cf9b7b045d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -488,6 +488,8 @@ filterwarnings = [ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", + # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", # https://github.com/fwestenberg/devialet/pull/6 - >1.4.5 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api", # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 @@ -504,13 +506,23 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", # https://github.com/hunterjm/python-onvif-zeep-async/pull/51 - >3.1.12 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client", + # https://github.com/googleapis/python-pubsub/commit/060f00bcea5cd129be3a2d37078535cc97b4f5e8 - >=2.13.12 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:google.pubsub_v1.services.publisher.client", # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", + # https://github.com/timmo001/system-bridge-connector/pull/27 - >= 4.1.0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:systembridgeconnector.version", + # https://github.com/jschlyter/ttls/commit/d64f1251397b8238cf6a35bea64784de25e3386c - >=1.8.1 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:ttls", # https://github.com/mvantellingen/python-zeep/pull/1364 - >4.2.1 "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:zeep.utils", + # -- fixed for Python 3.13 + # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:wyoming.audio", + # -- other # Locale changes might take some time to resolve upstream "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", @@ -537,15 +549,47 @@ filterwarnings = [ # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 # https://github.com/koolsb/pyblackbird/pull/9 -> closed "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", - # https://pypi.org/project/pybotvac/ - v0.0.24 - 2023-01-02 - # https://github.com/stianaske/pybotvac/pull/81 -> closed - "ignore:invalid escape sequence:SyntaxWarning:.*pybotvac.robot", - # https://github.com/pkkid/python-plexapi/pull/1244 - v4.15.10 -> new issue same file + # https://github.com/pkkid/python-plexapi/pull/1244 - v4.15.11 -> new issue same file + # https://github.com/pkkid/python-plexapi/pull/1370 -> Not fixed here "ignore:invalid escape sequence:SyntaxWarning:.*plexapi.base", # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", + # - pkg_resources + # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", + # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", + # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", + # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", + # https://pypi.org/project/velbus-aio/ - v2024.4.0 + # https://github.com/Cereal2nd/velbus-aio/blob/2024.4.0/velbusaio/handler.py#L13 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:velbusaio.handler", + + # -- Python 3.13 + # HomeAssistant + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api", + # https://pypi.org/project/pylutron/ - v0.2.12 - 2024-02-12 + # https://github.com/thecynic/pylutron/issues/89 + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pylutron", + # https://pypi.org/project/SpeechRecognition/ - v3.10.3 - 2024-03-30 + # https://github.com/Uberi/speech_recognition/blob/3.10.3/speech_recognition/__init__.py#L7 + "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", + # https://pypi.org/project/voip-utils/ - v0.1.0 - 2023-06-28 + # https://github.com/home-assistant-libs/voip-utils/blob/v0.1.0/voip_utils/rtp_audio.py#L2 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio", + + # -- Python 3.13 - unmaintained projects, last release about 2+ years + # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pydub.utils", + # https://github.com/heathbar/plum-lightpad-python/issues/7 - v0.0.11 - 2018-10-16 + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:plumlightpad.lightpad", + # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 + # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2 + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i", # -- unmaintained projects, last release about 2+ years # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 @@ -559,6 +603,10 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", # https://pypi.org/project/foobot_async/ - v1.0.0 - 2020-11-24 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/habitipy/ - v0.3.0 - 2019-01-14 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", + # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", # https://pypi.org/project/influxdb/ - v5.3.1 - 2020-11-11 (archived) "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` @@ -575,6 +623,8 @@ filterwarnings = [ "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", + # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight", # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)", @@ -586,6 +636,10 @@ filterwarnings = [ "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", + # https://pypi.org/project/pyowm/ - v3.3.0 - 2022-02-14 + # https://github.com/csparpa/pyowm/issues/435 + # https://github.com/csparpa/pyowm/blob/3.3.0/pyowm/commons/cityidregistry.py#L7 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pyowm.commons.cityidregistry", # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 From 137514edb7ad0b2fbda9fa3f46f17235a68ce35b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Apr 2024 11:58:56 -1000 Subject: [PATCH 27/64] Bump aiohttp to 3.9.4 (#110730) * Bump aiohttp to 3.9.4 This is rc0 for now but will be updated when the full release it out * cleanup cruft * regen * fix tests (these changes are fine) * chunk size is too small to read since boundry is now enforced * chunk size is too small to read since boundry is now enforced --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/file_upload/test_init.py | 8 ++++---- tests/components/websocket_api/test_auth.py | 2 +- tests/components/websocket_api/test_http.py | 6 +++--- tests/components/websocket_api/test_init.py | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4acbe3fae58..1150da9ceac 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.0.0 aiodns==3.2.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.3.1 -aiohttp==3.9.3 +aiohttp==3.9.4 aiohttp_cors==0.7.0 astral==2.2 async-interrupt==1.1.1 diff --git a/pyproject.toml b/pyproject.toml index cf9b7b045d6..8be7b9b40f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ requires-python = ">=3.12.0" dependencies = [ "aiodns==3.2.0", - "aiohttp==3.9.3", + "aiohttp==3.9.4", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", "aiohttp-zlib-ng==0.3.1", diff --git a/requirements.txt b/requirements.txt index c5b5e54046d..3cd1e8edfa5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohttp==3.9.3 +aiohttp==3.9.4 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.3.1 diff --git a/tests/components/file_upload/test_init.py b/tests/components/file_upload/test_init.py index 1ef238cafd0..fa77f6e55f5 100644 --- a/tests/components/file_upload/test_init.py +++ b/tests/components/file_upload/test_init.py @@ -90,9 +90,9 @@ async def test_upload_large_file( file_upload.TEMP_DIR_NAME + f"-{getrandbits(10):03x}", ), patch( - # Patch one megabyte to 8 bytes to prevent having to use big files in tests + # Patch one megabyte to 50 bytes to prevent having to use big files in tests "homeassistant.components.file_upload.ONE_MEGABYTE", - 8, + 50, ), ): res = await client.post("/api/file_upload", data={"file": large_file_io}) @@ -152,9 +152,9 @@ async def test_upload_large_file_fails( file_upload.TEMP_DIR_NAME + f"-{getrandbits(10):03x}", ), patch( - # Patch one megabyte to 8 bytes to prevent having to use big files in tests + # Patch one megabyte to 50 bytes to prevent having to use big files in tests "homeassistant.components.file_upload.ONE_MEGABYTE", - 8, + 50, ), patch( "homeassistant.components.file_upload.Path.open", return_value=_mock_open() diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 35bf2402b6c..595dc7dcc32 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -221,7 +221,7 @@ async def test_auth_close_after_revoke( hass.auth.async_remove_refresh_token(refresh_token) msg = await websocket_client.receive() - assert msg.type == aiohttp.WSMsgType.CLOSED + assert msg.type is aiohttp.WSMsgType.CLOSE assert websocket_client.closed diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index db186e4811b..6ce46a5d9fe 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -43,7 +43,7 @@ async def test_pending_msg_overflow( for idx in range(10): await websocket_client.send_json({"id": idx + 1, "type": "ping"}) msg = await websocket_client.receive() - assert msg.type == WSMsgType.CLOSED + assert msg.type is WSMsgType.CLOSE async def test_cleanup_on_cancellation( @@ -249,7 +249,7 @@ async def test_pending_msg_peak( ) msg = await websocket_client.receive() - assert msg.type == WSMsgType.CLOSED + assert msg.type is WSMsgType.CLOSE assert "Client unable to keep up with pending messages" in caplog.text assert "Stayed over 5 for 5 seconds" in caplog.text assert "overload" in caplog.text @@ -297,7 +297,7 @@ async def test_pending_msg_peak_recovery( msg = await websocket_client.receive() assert msg.type == WSMsgType.TEXT msg = await websocket_client.receive() - assert msg.type == WSMsgType.CLOSED + assert msg.type is WSMsgType.CLOSE assert "Client unable to keep up with pending messages" not in caplog.text diff --git a/tests/components/websocket_api/test_init.py b/tests/components/websocket_api/test_init.py index 9360ff4ef8a..b20fd1c2f7e 100644 --- a/tests/components/websocket_api/test_init.py +++ b/tests/components/websocket_api/test_init.py @@ -41,7 +41,7 @@ async def test_quiting_hass(hass: HomeAssistant, websocket_client) -> None: msg = await websocket_client.receive() - assert msg.type == WSMsgType.CLOSED + assert msg.type is WSMsgType.CLOSE async def test_unknown_command(websocket_client) -> None: From a093f943d7136af12eb88acb993332b7882c76fe Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 12 Apr 2024 00:03:10 +0200 Subject: [PATCH 28/64] Use library classes instead of namedtuple in ipma tests (#115372) --- tests/components/ipma/__init__.py | 149 ++++++++---------- .../ipma/snapshots/test_diagnostics.ambr | 57 ++----- .../ipma/snapshots/test_weather.ambr | 8 +- 3 files changed, 83 insertions(+), 131 deletions(-) diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py index 65cff43c8d4..799120e3966 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -1,8 +1,12 @@ """Tests for the IPMA component.""" -from collections import namedtuple from datetime import UTC, datetime +from pyipma.forecast import Forecast, Forecast_Location, Weather_Type +from pyipma.observation import Observation +from pyipma.rcm import RCM +from pyipma.uv import UV + from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME ENTRY_CONFIG = { @@ -18,109 +22,90 @@ class MockLocation: async def fire_risk(self, api): """Mock Fire Risk.""" - RCM = namedtuple( - "RCM", - [ - "dico", - "rcm", - "coordinates", - ], - ) return RCM("some place", 3, (0, 0)) async def uv_risk(self, api): """Mock UV Index.""" - UV = namedtuple( - "UV", - ["idPeriodo", "intervaloHora", "data", "globalIdLocal", "iUv"], - ) - return UV(0, "0", datetime.now(), 0, 5.7) + return UV(0, "0", datetime(2020, 1, 16, 0, 0, 0), 0, 5.7) async def observation(self, api): """Mock Observation.""" - Observation = namedtuple( - "Observation", - [ - "accumulated_precipitation", - "humidity", - "pressure", - "radiation", - "temperature", - "wind_direction", - "wind_intensity_km", - ], + return Observation( + precAcumulada=0.0, + humidade=71.0, + pressao=1000.0, + radiacao=0.0, + temperatura=18.0, + idDireccVento=8, + intensidadeVentoKM=3.94, + intensidadeVento=1.0944, + timestamp=datetime(2020, 1, 16, 0, 0, 0), + idEstacao=0, ) - return Observation(0.0, 71.0, 1000.0, 0.0, 18.0, "NW", 3.94) - async def forecast(self, api, period): """Mock Forecast.""" - Forecast = namedtuple( - "Forecast", - [ - "feels_like_temperature", - "forecast_date", - "forecasted_hours", - "humidity", - "max_temperature", - "min_temperature", - "precipitation_probability", - "temperature", - "update_date", - "weather_type", - "wind_direction", - "wind_strength", - ], - ) - - WeatherType = namedtuple("WeatherType", ["id", "en", "pt"]) if period == 24: return [ Forecast( - None, - datetime(2020, 1, 16, 0, 0, 0), - 24, - None, - 16.2, - 10.6, - "100.0", - 13.4, - "2020-01-15T07:51:00", - WeatherType(9, "Rain/showers", "Chuva/aguaceiros"), - "S", - "10", + utci=None, + dataPrev=datetime(2020, 1, 16, 0, 0, 0), + idPeriodo=24, + hR=None, + tMax=16.2, + tMin=10.6, + probabilidadePrecipita=100.0, + tMed=13.4, + dataUpdate=datetime(2020, 1, 15, 7, 51, 0), + idTipoTempo=Weather_Type(9, "Rain/showers", "Chuva/aguaceiros"), + ddVento="S", + ffVento=10, + idFfxVento=0, + iUv=0, + intervaloHora="", + location=Forecast_Location(0, "", 0, 0, 0, "", (0, 0)), ), ] if period == 1: return [ Forecast( - "7.7", - datetime(2020, 1, 15, 1, 0, 0, tzinfo=UTC), - 1, - "86.9", - 12.0, - None, - 80.0, - 10.6, - "2020-01-15T02:51:00", - WeatherType(10, "Light rain", "Chuva fraca ou chuvisco"), - "S", - "32.7", + utci=7.7, + dataPrev=datetime(2020, 1, 15, 1, 0, 0, tzinfo=UTC), + idPeriodo=1, + hR=86.9, + tMax=12.0, + tMin=None, + probabilidadePrecipita=80.0, + tMed=10.6, + dataUpdate=datetime(2020, 1, 15, 2, 51, 0), + idTipoTempo=Weather_Type( + 10, "Light rain", "Chuva fraca ou chuvisco" + ), + ddVento="S", + ffVento=32.7, + idFfxVento=0, + iUv=0, + intervaloHora="", + location=Forecast_Location(0, "", 0, 0, 0, "", (0, 0)), ), Forecast( - "5.7", - datetime(2020, 1, 15, 2, 0, 0, tzinfo=UTC), - 1, - "86.9", - 12.0, - None, - 80.0, - 10.6, - "2020-01-15T02:51:00", - WeatherType(1, "Clear sky", "C\u00e9u limpo"), - "S", - "32.7", + utci=5.7, + dataPrev=datetime(2020, 1, 15, 2, 0, 0, tzinfo=UTC), + idPeriodo=1, + hR=86.9, + tMax=12.0, + tMin=None, + probabilidadePrecipita=80.0, + tMed=10.6, + dataUpdate=datetime(2020, 1, 15, 2, 51, 0), + idTipoTempo=Weather_Type(1, "Clear sky", "C\u00e9u limpo"), + ddVento="S", + ffVento=32.7, + idFfxVento=0, + iUv=0, + intervaloHora="", + location=Forecast_Location(0, "", 0, 0, 0, "", (0, 0)), ), ] diff --git a/tests/components/ipma/snapshots/test_diagnostics.ambr b/tests/components/ipma/snapshots/test_diagnostics.ambr index c95364b6e4a..9d7d38db8c3 100644 --- a/tests/components/ipma/snapshots/test_diagnostics.ambr +++ b/tests/components/ipma/snapshots/test_diagnostics.ambr @@ -1,15 +1,10 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'current_weather': list([ - 0.0, - 71.0, - 1000.0, - 0.0, - 18.0, - 'NW', - 3.94, - ]), + 'current_weather': dict({ + '__type': "", + 'repr': 'Observation(intensidadeVentoKM=3.94, temperatura=18.0, radiacao=0.0, idDireccVento=8, precAcumulada=0.0, intensidadeVento=1.0944, humidade=71.0, pressao=1000.0, timestamp=datetime.datetime(2020, 1, 16, 0, 0), idEstacao=0)', + }), 'location_information': dict({ 'global_id_local': 1130600, 'id_station': 1200545, @@ -19,42 +14,14 @@ 'station': 'HomeTown Station', }), 'weather_forecast': list([ - list([ - '7.7', - '2020-01-15T01:00:00+00:00', - 1, - '86.9', - 12.0, - None, - 80.0, - 10.6, - '2020-01-15T02:51:00', - list([ - 10, - 'Light rain', - 'Chuva fraca ou chuvisco', - ]), - 'S', - '32.7', - ]), - list([ - '5.7', - '2020-01-15T02:00:00+00:00', - 1, - '86.9', - 12.0, - None, - 80.0, - 10.6, - '2020-01-15T02:51:00', - list([ - 1, - 'Clear sky', - 'Céu limpo', - ]), - 'S', - '32.7', - ]), + dict({ + '__type': "", + 'repr': "Forecast(tMed=10.6, tMin=None, ffVento=32.7, idFfxVento=0, dataUpdate=datetime.datetime(2020, 1, 15, 2, 51), tMax=12.0, iUv=0, intervaloHora='', idTipoTempo=Weather_Type(id=10, en='Light rain', pt='Chuva fraca ou chuvisco'), hR=86.9, location=Forecast_Location(globalIdLocal=0, local='', idRegiao=0, idDistrito=0, idConcelho=0, idAreaAviso='', coordinates=(0, 0)), probabilidadePrecipita=80.0, idPeriodo=1, dataPrev=datetime.datetime(2020, 1, 15, 1, 0, tzinfo=datetime.timezone.utc), ddVento='S', utci=7.7)", + }), + dict({ + '__type': "", + 'repr': "Forecast(tMed=10.6, tMin=None, ffVento=32.7, idFfxVento=0, dataUpdate=datetime.datetime(2020, 1, 15, 2, 51), tMax=12.0, iUv=0, intervaloHora='', idTipoTempo=Weather_Type(id=1, en='Clear sky', pt='Céu limpo'), hR=86.9, location=Forecast_Location(globalIdLocal=0, local='', idRegiao=0, idDistrito=0, idConcelho=0, idAreaAviso='', coordinates=(0, 0)), probabilidadePrecipita=80.0, idPeriodo=1, dataPrev=datetime.datetime(2020, 1, 15, 2, 0, tzinfo=datetime.timezone.utc), ddVento='S', utci=5.7)", + }), ]), }) # --- diff --git a/tests/components/ipma/snapshots/test_weather.ambr b/tests/components/ipma/snapshots/test_weather.ambr index 0a778776329..1142cb7cfe5 100644 --- a/tests/components/ipma/snapshots/test_weather.ambr +++ b/tests/components/ipma/snapshots/test_weather.ambr @@ -83,7 +83,7 @@ dict({ 'condition': 'rainy', 'datetime': datetime.datetime(2020, 1, 16, 0, 0), - 'precipitation_probability': '100.0', + 'precipitation_probability': 100.0, 'temperature': 16.2, 'templow': 10.6, 'wind_bearing': 'S', @@ -121,7 +121,7 @@ dict({ 'condition': 'rainy', 'datetime': datetime.datetime(2020, 1, 16, 0, 0), - 'precipitation_probability': '100.0', + 'precipitation_probability': 100.0, 'temperature': 16.2, 'templow': 10.6, 'wind_bearing': 'S', @@ -160,7 +160,7 @@ dict({ 'condition': 'rainy', 'datetime': '2020-01-16T00:00:00', - 'precipitation_probability': '100.0', + 'precipitation_probability': 100.0, 'temperature': 16.2, 'templow': 10.6, 'wind_bearing': 'S', @@ -173,7 +173,7 @@ dict({ 'condition': 'rainy', 'datetime': '2020-01-16T00:00:00', - 'precipitation_probability': '100.0', + 'precipitation_probability': 100.0, 'temperature': 16.2, 'templow': 10.6, 'wind_bearing': 'S', From 2e8f4743eb89e44111e1979d5ca344ec69665ba9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Apr 2024 12:17:01 -1000 Subject: [PATCH 29/64] Fix flakey mobile app webhook test (#115447) --- tests/components/mobile_app/test_webhook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index c67312939b1..f39c963b45b 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -2,7 +2,7 @@ from binascii import unhexlify from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest @@ -317,7 +317,7 @@ async def test_webhook_handle_get_config( "time_zone": hass_config["time_zone"], "components": set(hass_config["components"]), "version": hass_config["version"], - "theme_color": "#03A9F4", # Default frontend theme color + "theme_color": ANY, "entities": { "mock-device-id": {"disabled": False}, "battery-state-id": {"disabled": False}, From cfda8f64b4b58049b23aa5b6cde4e3ed644a55a2 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 11 Apr 2024 19:04:51 -0400 Subject: [PATCH 30/64] Bump python-roborock to 2.0.0 (#115449) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 711da78de31..d03aa68f1a6 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==1.0.0", + "python-roborock==2.0.0", "vacuum-map-parser-roborock==0.1.1" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 76886fa4b96..4ee717dd0d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2294,7 +2294,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==1.0.0 +python-roborock==2.0.0 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bba41cc4d8d..479449d92cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1773,7 +1773,7 @@ python-qbittorrent==0.4.3 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==1.0.0 +python-roborock==2.0.0 # homeassistant.components.smarttub python-smarttub==0.0.36 From e3680044fedc253ec00649103fad5fe34ae4f72a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Apr 2024 13:26:03 -1000 Subject: [PATCH 31/64] Fix flakey influxdb test (#115442) --- tests/components/influxdb/test_init.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index cd95248eb33..ad3fddeaf6e 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -1,5 +1,6 @@ """The tests for the InfluxDB component.""" +import asyncio from dataclasses import dataclass import datetime from http import HTTPStatus @@ -1572,12 +1573,21 @@ async def test_invalid_inputs_error( await _setup(hass, mock_client, config_ext, get_write_api) write_api = get_write_api(mock_client) - write_api.side_effect = test_exception + + write_api_done_event = asyncio.Event() + + def wait_for_write(*args, **kwargs): + hass.loop.call_soon_threadsafe(write_api_done_event.set) + raise test_exception + + write_api.side_effect = wait_for_write with patch(f"{INFLUX_PATH}.time.sleep") as sleep: + write_api_done_event.clear() hass.states.async_set("fake.something", 1) await hass.async_block_till_done() await async_wait_for_queue_to_process(hass) + await write_api_done_event.wait() await hass.async_block_till_done() write_api.assert_called_once() From a48f2803b23354a9df3978f4bded34053bef18aa Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Apr 2024 04:14:10 +0200 Subject: [PATCH 32/64] Add py.typed file (#115446) --- homeassistant/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 homeassistant/py.typed diff --git a/homeassistant/py.typed b/homeassistant/py.typed new file mode 100644 index 00000000000..e69de29bb2d From 1b24e78dd911ec4655a9b0b354708f3972244a2b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Apr 2024 04:14:37 +0200 Subject: [PATCH 33/64] Improve FlowHandler menu_options typing (#115296) --- homeassistant/data_entry_flow.py | 6 +++--- homeassistant/helpers/schema_config_entry_flow.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 649c9fdf8a4..7e7019681af 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import abc import asyncio -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Callable, Container, Iterable, Mapping from contextlib import suppress import copy from dataclasses import dataclass @@ -153,7 +153,7 @@ class FlowResult(TypedDict, Generic[_HandlerT], total=False): flow_id: Required[str] handler: Required[_HandlerT] last_step: bool | None - menu_options: list[str] | dict[str, str] + menu_options: Container[str] options: Mapping[str, Any] preview: str | None progress_action: str @@ -843,7 +843,7 @@ class FlowHandler(Generic[_FlowResultT, _HandlerT]): self, *, step_id: str | None = None, - menu_options: list[str] | dict[str, str], + menu_options: Container[str], description_placeholders: Mapping[str, str] | None = None, ) -> _FlowResultT: """Show a navigation menu to the user. diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 978ce949eb3..67624bfb368 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Callable, Coroutine, Mapping +from collections.abc import Callable, Container, Coroutine, Mapping import copy from dataclasses import dataclass import types @@ -102,7 +102,7 @@ class SchemaFlowMenuStep(SchemaFlowStep): """Define a config or options flow menu step.""" # Menu options - options: list[str] | dict[str, str] + options: Container[str] class SchemaCommonFlowHandler: From c7cb0237d1afb600581f4cbfb41cf4e063d5cd39 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 11 Apr 2024 19:14:52 -0700 Subject: [PATCH 34/64] Fix bug in rainbird switch when turning off a switch that is already off (#115421) Fix big in rainbird switch when turning off a switch that is already off Co-authored-by: J. Nick Koston --- homeassistant/components/rainbird/switch.py | 3 ++- tests/components/rainbird/test_switch.py | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index a929f5b875b..7f43553aa41 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -123,7 +123,8 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) # The device reflects the old state for a few moments. Update the # state manually and trigger a refresh after a short debounced delay. - self.coordinator.data.active_zones.remove(self._zone) + if self.is_on: + self.coordinator.data.active_zones.remove(self._zone) self.async_write_ha_state() await self.coordinator.async_request_refresh() diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index f87b7f121b5..1352a4a633d 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -146,20 +146,24 @@ async def test_switch_on( @pytest.mark.parametrize( - "zone_state_response", - [ZONE_3_ON_RESPONSE], + ("zone_state_response", "start_state"), + [ + (ZONE_3_ON_RESPONSE, "on"), + (ZONE_OFF_RESPONSE, "off"), # Already off + ], ) async def test_switch_off( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], + start_state: str, ) -> None: """Test turning off irrigation switch.""" # Initially the test zone is on zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None - assert zone.state == "on" + assert zone.state == start_state aioclient_mock.mock_calls.clear() responses.extend( From 28bdbec14e3960e1d91bcf7899b8dd990f1015e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Apr 2024 16:16:01 -1000 Subject: [PATCH 35/64] Bypass ConfigEntry __setattr__ in __init__ (#115405) ConfigEntries.async_initialize was trigger asyncio warnings because of the CPU time to call __setattr__ for every variable for each ConfigEntry being loaded at startup --- homeassistant/config_entries.py | 50 +++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index dd48c53160e..7c1b590b1b0 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -282,7 +282,23 @@ class ConfigEntry: pref_disable_new_entities: bool pref_disable_polling: bool version: int + source: str minor_version: int + disabled_by: ConfigEntryDisabler | None + supports_unload: bool | None + supports_remove_device: bool | None + _supports_options: bool | None + _supports_reconfigure: bool | None + update_listeners: list[UpdateListenerType] + _async_cancel_retry_setup: Callable[[], Any] | None + _on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None + reload_lock: asyncio.Lock + _reauth_lock: asyncio.Lock + _reconfigure_lock: asyncio.Lock + _tasks: set[asyncio.Future[Any]] + _background_tasks: set[asyncio.Future[Any]] + _integration_for_domain: loader.Integration | None + _tries: int def __init__( self, @@ -334,7 +350,7 @@ class ConfigEntry: _setter(self, "pref_disable_polling", pref_disable_polling) # Source of the configuration (user, discovery, cloud) - self.source = source + _setter(self, "source", source) # State of the entry (LOADED, NOT_LOADED) _setter(self, "state", state) @@ -355,22 +371,22 @@ class ConfigEntry: error_if_core=False, ) disabled_by = ConfigEntryDisabler(disabled_by) - self.disabled_by = disabled_by + _setter(self, "disabled_by", disabled_by) # Supports unload - self.supports_unload: bool | None = None + _setter(self, "supports_unload", None) # Supports remove device - self.supports_remove_device: bool | None = None + _setter(self, "supports_remove_device", None) # Supports options - self._supports_options: bool | None = None + _setter(self, "_supports_options", None) # Supports reconfigure - self._supports_reconfigure: bool | None = None + _setter(self, "_supports_reconfigure", None) # Listeners to call on update - self.update_listeners: list[UpdateListenerType] = [] + _setter(self, "update_listeners", []) # Reason why config entry is in a failed state _setter(self, "reason", None) @@ -378,25 +394,23 @@ class ConfigEntry: _setter(self, "error_reason_translation_placeholders", None) # Function to cancel a scheduled retry - self._async_cancel_retry_setup: Callable[[], Any] | None = None + _setter(self, "_async_cancel_retry_setup", None) # Hold list for actions to call on unload. - self._on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None = ( - None - ) + _setter(self, "_on_unload", None) # Reload lock to prevent conflicting reloads - self.reload_lock = asyncio.Lock() + _setter(self, "reload_lock", asyncio.Lock()) # Reauth lock to prevent concurrent reauth flows - self._reauth_lock = asyncio.Lock() + _setter(self, "_reauth_lock", asyncio.Lock()) # Reconfigure lock to prevent concurrent reconfigure flows - self._reconfigure_lock = asyncio.Lock() + _setter(self, "_reconfigure_lock", asyncio.Lock()) - self._tasks: set[asyncio.Future[Any]] = set() - self._background_tasks: set[asyncio.Future[Any]] = set() + _setter(self, "_tasks", set()) + _setter(self, "_background_tasks", set()) - self._integration_for_domain: loader.Integration | None = None - self._tries = 0 + _setter(self, "_integration_for_domain", None) + _setter(self, "_tries", 0) def __repr__(self) -> str: """Representation of ConfigEntry.""" From fb5fc136e88335dab88425280b930e141deb11e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Apr 2024 16:32:47 -1000 Subject: [PATCH 36/64] Avoid falling back to event loop import on ModuleNotFound (#115404) --- homeassistant/loader.py | 4 ++ tests/test_loader.py | 90 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index da8159ca2cf..1a72c8eb351 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -976,6 +976,8 @@ class Integration: comp = await self.hass.async_add_import_executor_job( self._get_component, True ) + except ModuleNotFoundError: + raise except ImportError as ex: load_executor = False _LOGGER.debug( @@ -1115,6 +1117,8 @@ class Integration: self._load_platforms, platform_names ) ) + except ModuleNotFoundError: + raise except ImportError as ex: _LOGGER.debug( "Failed to import %s platforms %s in executor", diff --git a/tests/test_loader.py b/tests/test_loader.py index 41796f2f7d2..404858200bc 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1471,6 +1471,50 @@ async def test_async_get_component_deadlock_fallback( assert module is module_mock +async def test_async_get_component_deadlock_fallback_module_not_found( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Verify async_get_component fallback behavior. + + Ensure that fallback is not triggered on ModuleNotFoundError. + """ + executor_import_integration = _get_test_integration( + hass, "executor_import", True, import_executor=True + ) + assert executor_import_integration.import_executor is True + module_mock = MagicMock(__file__="__init__.py") + import_attempts = 0 + + def mock_import(module: str, *args: Any, **kwargs: Any) -> Any: + nonlocal import_attempts + if module == "homeassistant.components.executor_import": + import_attempts += 1 + + if import_attempts == 1: + raise ModuleNotFoundError( + "homeassistant.components.executor_import not found", + name="homeassistant.components.executor_import", + ) + + return module_mock + + assert "homeassistant.components.executor_import" not in sys.modules + assert "custom_components.executor_import" not in sys.modules + with ( + patch("homeassistant.loader.importlib.import_module", mock_import), + pytest.raises( + ModuleNotFoundError, match="homeassistant.components.executor_import" + ), + ): + await executor_import_integration.async_get_component() + + # We should not have tried to fall back to the event loop import + assert "loaded_executor=False" not in caplog.text + assert "homeassistant.components.executor_import" not in sys.modules + assert "custom_components.executor_import" not in sys.modules + assert import_attempts == 1 + + async def test_async_get_component_raises_after_import_failure( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1551,6 +1595,52 @@ async def test_async_get_platform_deadlock_fallback( assert module is module_mock +async def test_async_get_platform_deadlock_fallback_module_not_found( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Verify async_get_platform fallback behavior. + + Ensure that fallback is not triggered on ModuleNotFoundError. + """ + executor_import_integration = _get_test_integration( + hass, "executor_import", True, import_executor=True + ) + assert executor_import_integration.import_executor is True + module_mock = MagicMock() + import_attempts = 0 + + def mock_import(module: str, *args: Any, **kwargs: Any) -> Any: + nonlocal import_attempts + if module == "homeassistant.components.executor_import.config_flow": + import_attempts += 1 + + if import_attempts == 1: + raise ModuleNotFoundError( + "Not found homeassistant.components.executor_import.config_flow", + name="homeassistant.components.executor_import.config_flow", + ) + + return module_mock + + assert "homeassistant.components.executor_import" not in sys.modules + assert "custom_components.executor_import" not in sys.modules + with ( + patch("homeassistant.loader.importlib.import_module", mock_import), + pytest.raises( + ModuleNotFoundError, + match="homeassistant.components.executor_import.config_flow", + ), + ): + await executor_import_integration.async_get_platform("config_flow") + + # We should not have tried to fall back to the event loop import + assert "executor=['config_flow']" in caplog.text + assert "loop=['config_flow']" not in caplog.text + assert "homeassistant.components.executor_import" not in sys.modules + assert "custom_components.executor_import" not in sys.modules + assert import_attempts == 1 + + async def test_async_get_platform_raises_after_import_failure( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 6cc2b1e10a14e69643dae8e4224acbd7a02ed0df Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 12 Apr 2024 08:39:59 +0200 Subject: [PATCH 37/64] Use enum device class in Netatmo wind direction (#115413) * Use enum device class in Netatmo wind direction * Use enum device class in Netatmo wind direction --------- Co-authored-by: J. Nick Koston --- homeassistant/components/netatmo/sensor.py | 17 ++++++ homeassistant/components/netatmo/strings.json | 24 +++++++- .../netatmo/snapshots/test_sensor.ambr | 56 +++++++++++++++++-- tests/components/netatmo/test_sensor.py | 6 +- 4 files changed, 92 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 4e470437f7a..6e96a73135f 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -67,6 +67,17 @@ from .helper import NetatmoArea _LOGGER = logging.getLogger(__name__) +DIRECTION_OPTIONS = [ + "n", + "ne", + "e", + "se", + "s", + "sw", + "w", + "nw", +] + def process_health(health: StateType) -> str | None: """Process health index and return string for display.""" @@ -199,6 +210,9 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="windangle", netatmo_name="wind_direction", + device_class=SensorDeviceClass.ENUM, + options=DIRECTION_OPTIONS, + value_fn=lambda x: x.lower() if isinstance(x, str) else None, ), NetatmoSensorEntityDescription( key="windangle_value", @@ -218,6 +232,9 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="gustangle", netatmo_name="gust_direction", entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=DIRECTION_OPTIONS, + value_fn=lambda x: x.lower() if isinstance(x, str) else None, ), NetatmoSensorEntityDescription( key="gustangle_value", diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index f6aba92d005..b8840c27006 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -185,13 +185,33 @@ "name": "Precipitation today" }, "wind_direction": { - "name": "Wind direction" + "name": "Wind direction", + "state": { + "n": "North", + "ne": "North-east", + "e": "East", + "se": "South-east", + "s": "South", + "sw": "South-west", + "w": "West", + "nw": "North-west" + } }, "wind_angle": { "name": "Wind angle" }, "gust_direction": { - "name": "Gust direction" + "name": "Gust direction", + "state": { + "n": "[%key:component::netatmo::entity::sensor::wind_direction::state::n%]", + "ne": "[%key:component::netatmo::entity::sensor::wind_direction::state::ne%]", + "e": "[%key:component::netatmo::entity::sensor::wind_direction::state::e%]", + "se": "[%key:component::netatmo::entity::sensor::wind_direction::state::se%]", + "s": "[%key:component::netatmo::entity::sensor::wind_direction::state::s%]", + "sw": "[%key:component::netatmo::entity::sensor::wind_direction::state::sw%]", + "w": "[%key:component::netatmo::entity::sensor::wind_direction::state::w%]", + "nw": "[%key:component::netatmo::entity::sensor::wind_direction::state::nw%]" + } }, "gust_angle": { "name": "Gust angle" diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index ed5f4decc86..b6dacb1911c 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -6073,7 +6073,18 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'n', + 'ne', + 'e', + 'se', + 's', + 'sw', + 'w', + 'nw', + ]), + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -6090,7 +6101,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust direction', 'platform': 'netatmo', @@ -6105,14 +6116,25 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'enum', 'friendly_name': 'Villa Garden Gust direction', + 'options': list([ + 'n', + 'ne', + 'e', + 'se', + 's', + 'sw', + 'w', + 'nw', + ]), }), 'context': , 'entity_id': 'sensor.villa_garden_gust_direction', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'S', + 'state': 's', }) # --- # name: test_entity[sensor.villa_garden_gust_strength-entry] @@ -6317,7 +6339,18 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'n', + 'ne', + 'e', + 'se', + 's', + 'sw', + 'w', + 'nw', + ]), + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -6334,7 +6367,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Wind direction', 'platform': 'netatmo', @@ -6349,14 +6382,25 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'enum', 'friendly_name': 'Villa Garden Wind direction', + 'options': list([ + 'n', + 'ne', + 'e', + 'se', + 's', + 'sw', + 'w', + 'nw', + ]), }), 'context': , 'entity_id': 'sensor.villa_garden_wind_direction', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'SW', + 'state': 'sw', }) # --- # name: test_entity[sensor.villa_garden_wind_speed-entry] diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index d2cc20b8394..4a6233e17e1 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -165,17 +165,17 @@ async def test_process_health(health: int, expected: str) -> None: ), ("12:34:56:80:c1:ea-sum_rain_1", "villa_rain_rain_last_hour", "0"), ("12:34:56:80:c1:ea-sum_rain_24", "villa_rain_rain_today", "6.9"), - ("12:34:56:03:1b:e4-windangle", "netatmoindoor_garden_direction", "SW"), + ("12:34:56:03:1b:e4-windangle", "netatmoindoor_garden_direction", "sw"), ( "12:34:56:03:1b:e4-windangle_value", "netatmoindoor_garden_angle", "217", ), - ("12:34:56:03:1b:e4-gustangle", "mystation_garden_gust_direction", "S"), + ("12:34:56:03:1b:e4-gustangle", "mystation_garden_gust_direction", "s"), ( "12:34:56:03:1b:e4-gustangle", "netatmoindoor_garden_gust_direction", - "S", + "s", ), ( "12:34:56:03:1b:e4-gustangle_value", From adafdb2b2d73bca550cbc833286768a8cbc97a0c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 12 Apr 2024 08:40:27 +0200 Subject: [PATCH 38/64] Use enum device class in Netatmo health index sensor (#115409) --- homeassistant/components/netatmo/sensor.py | 17 ++- homeassistant/components/netatmo/strings.json | 9 +- .../netatmo/snapshots/test_sensor.ambr | 104 ++++++++++++++++-- tests/components/netatmo/test_sensor.py | 4 +- 4 files changed, 110 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 6e96a73135f..fd40bbf88b6 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -83,15 +83,12 @@ def process_health(health: StateType) -> str | None: """Process health index and return string for display.""" if not isinstance(health, int): return None - if health == 0: - return "Healthy" - if health == 1: - return "Fine" - if health == 2: - return "Fair" - if health == 3: - return "Poor" - return "Unhealthy" + return { + 0: "healthy", + 1: "fine", + 2: "fair", + 3: "poor", + }.get(health, "unhealthy") def process_rf(strength: StateType) -> str | None: @@ -274,6 +271,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="health_idx", netatmo_name="health_idx", + device_class=SensorDeviceClass.ENUM, + options=["healthy", "fine", "fair", "poor", "unhealthy"], value_fn=process_health, ), NetatmoSensorEntityDescription( diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index b8840c27006..3c360634147 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -229,7 +229,14 @@ "name": "Wi-Fi" }, "health_idx": { - "name": "Health index" + "name": "Health index", + "state": { + "healthy": "Healthy", + "fine": "Fine", + "fair": "Fair", + "poor": "Poor", + "unhealthy": "Unhealthy" + } } } } diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index b6dacb1911c..0684956adb8 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -118,7 +118,15 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'healthy', + 'fine', + 'fair', + 'poor', + 'unhealthy', + ]), + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -135,7 +143,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Health index', 'platform': 'netatmo', @@ -150,16 +158,24 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'enum', 'friendly_name': 'Baby Bedroom Health index', 'latitude': 13.377726, 'longitude': 52.516263, + 'options': list([ + 'healthy', + 'fine', + 'fair', + 'poor', + 'unhealthy', + ]), }), 'context': , 'entity_id': 'sensor.baby_bedroom_health_index', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Fine', + 'state': 'fine', }) # --- # name: test_entity[sensor.baby_bedroom_humidity-entry] @@ -638,7 +654,15 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'healthy', + 'fine', + 'fair', + 'poor', + 'unhealthy', + ]), + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -655,7 +679,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Health index', 'platform': 'netatmo', @@ -670,7 +694,15 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'enum', 'friendly_name': 'Bedroom Health index', + 'options': list([ + 'healthy', + 'fine', + 'fair', + 'poor', + 'unhealthy', + ]), }), 'context': , 'entity_id': 'sensor.bedroom_health_index', @@ -2845,7 +2877,15 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'healthy', + 'fine', + 'fair', + 'poor', + 'unhealthy', + ]), + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -2862,7 +2902,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Health index', 'platform': 'netatmo', @@ -2877,9 +2917,17 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'enum', 'friendly_name': 'Kitchen Health index', 'latitude': 13.377726, 'longitude': 52.516263, + 'options': list([ + 'healthy', + 'fine', + 'fair', + 'poor', + 'unhealthy', + ]), }), 'context': , 'entity_id': 'sensor.kitchen_health_index', @@ -3916,7 +3964,15 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'healthy', + 'fine', + 'fair', + 'poor', + 'unhealthy', + ]), + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -3933,7 +3989,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Health index', 'platform': 'netatmo', @@ -3948,9 +4004,17 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'enum', 'friendly_name': 'Livingroom Health index', 'latitude': 13.377726, 'longitude': 52.516263, + 'options': list([ + 'healthy', + 'fine', + 'fair', + 'poor', + 'unhealthy', + ]), }), 'context': , 'entity_id': 'sensor.livingroom_health_index', @@ -4440,7 +4504,15 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'healthy', + 'fine', + 'fair', + 'poor', + 'unhealthy', + ]), + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -4457,7 +4529,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Health index', 'platform': 'netatmo', @@ -4472,16 +4544,24 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'enum', 'friendly_name': 'Parents Bedroom Health index', 'latitude': 13.377726, 'longitude': 52.516263, + 'options': list([ + 'healthy', + 'fine', + 'fair', + 'poor', + 'unhealthy', + ]), }), 'context': , 'entity_id': 'sensor.parents_bedroom_health_index', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Fine', + 'state': 'fine', }) # --- # name: test_entity[sensor.parents_bedroom_humidity-entry] diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 4a6233e17e1..4fa64e59b11 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -136,7 +136,7 @@ async def test_process_rf(strength: int, expected: str) -> None: @pytest.mark.parametrize( ("health", "expected"), - [(4, "Unhealthy"), (3, "Poor"), (2, "Fair"), (1, "Fine"), (0, "Healthy")], + [(4, "unhealthy"), (3, "poor"), (2, "fair"), (1, "fine"), (0, "healthy")], ) async def test_process_health(health: int, expected: str) -> None: """Test health index translation.""" @@ -195,7 +195,7 @@ async def test_process_health(health: int, expected: str) -> None: ( "12:34:56:26:68:92-health_idx", "baby_bedroom_health", - "Fine", + "fine", ), ( "12:34:56:26:68:92-wifi_status", From 35d3f2b29b8b920d00f3f192da339efcf98e1ff3 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Fri, 12 Apr 2024 09:02:22 +0200 Subject: [PATCH 39/64] Support backup of add-ons with hyphens (#115274) Co-authored-by: J. Nick Koston --- homeassistant/components/hassio/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index ce3b5b05ffe..972942caf52 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -196,7 +196,7 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( { vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.slug]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [VALID_ADDON_SLUG]), } ) @@ -211,7 +211,7 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( { vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.slug]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [VALID_ADDON_SLUG]), } ) From 9bf87329da3d6dc9a129e37b6e5862a5c4aba89c Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 12 Apr 2024 09:04:16 +0200 Subject: [PATCH 40/64] Enable Ruff FLY002 rule (#115112) Co-authored-by: J. Nick Koston Co-authored-by: Jan Bouwhuis --- .../components/azure_devops/__init__.py | 4 +- homeassistant/components/citybikes/sensor.py | 4 +- homeassistant/components/mqtt/discovery.py | 2 +- homeassistant/components/nextbus/sensor.py | 6 +- .../components/nmap_tracker/config_flow.py | 2 +- .../components/opower/coordinator.py | 10 +-- .../components/withings/config_flow.py | 10 +-- pyproject.toml | 1 + script/hassfest/mypy_config.py | 18 +---- .../test_device_trigger.py | 30 +++----- .../binary_sensor/test_device_condition.py | 24 +++--- .../binary_sensor/test_device_trigger.py | 60 ++++++--------- tests/components/cover/test_device_trigger.py | 15 ++-- tests/components/demo/test_vacuum.py | 4 +- .../components/device_automation/test_init.py | 12 ++- .../device_automation/test_toggle_entity.py | 60 ++++++--------- tests/components/fan/test_device_trigger.py | 15 ++-- tests/components/geo_location/test_trigger.py | 62 +++++++-------- .../triggers/test_numeric_state.py | 24 +++--- .../homeassistant/triggers/test_state.py | 47 +++++------- tests/components/homekit_controller/common.py | 2 +- .../humidifier/test_device_condition.py | 12 ++- .../humidifier/test_device_trigger.py | 45 +++++------ .../components/light/test_device_condition.py | 24 +++--- tests/components/light/test_device_trigger.py | 63 ++++------------ tests/components/lock/test_device_trigger.py | 60 ++++++--------- .../media_player/test_device_trigger.py | 15 ++-- tests/components/netatmo/test_init.py | 8 +- .../remote/test_device_condition.py | 24 +++--- .../components/remote/test_device_trigger.py | 75 ++++++++----------- .../sensor/test_device_condition.py | 24 ++++-- .../components/sensor/test_device_trigger.py | 75 ++++++++----------- tests/components/sun/test_trigger.py | 7 +- .../switch/test_device_condition.py | 24 +++--- .../components/switch/test_device_trigger.py | 75 ++++++++----------- tests/components/template/test_trigger.py | 75 ++++++++----------- .../components/update/test_device_trigger.py | 60 ++++++--------- .../components/vacuum/test_device_trigger.py | 15 ++-- tests/components/xbox/test_config_flow.py | 2 +- tests/components/zone/test_trigger.py | 34 ++++----- tests/scripts/test_auth.py | 4 +- 41 files changed, 474 insertions(+), 659 deletions(-) diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index deda8f466a6..537019fb9c1 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -118,8 +118,8 @@ class AzureDevOpsEntity(CoordinatorEntity[DataUpdateCoordinator[list[DevOpsBuild """Initialize the Azure DevOps entity.""" super().__init__(coordinator) self.entity_description = entity_description - self._attr_unique_id: str = "_".join( - [entity_description.organization, entity_description.key] + self._attr_unique_id: str = ( + f"{entity_description.organization}_{entity_description.key}" ) self._organization: str = entity_description.organization self._project_name: str = entity_description.project.name diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index de85e6309f9..4049a656caf 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -201,9 +201,9 @@ async def async_setup_platform( if radius > dist or stations_list.intersection((station_id, station_uid)): if name: - uid = "_".join([network.network_id, name, station_id]) + uid = f"{network.network_id}_{name}_{station_id}" else: - uid = "_".join([network.network_id, station_id]) + uid = f"{network.network_id}_{station_id}" entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, uid, hass=hass) devices.append(CityBikesStation(network, station_id, entity_id)) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 13c56a9b48e..43f4f8cfd46 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -268,7 +268,7 @@ async def async_start( # noqa: C901 availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}" # If present, the node_id will be included in the discovered object id - discovery_id = " ".join((node_id, object_id)) if node_id else object_id + discovery_id = f"{node_id} {object_id}" if node_id else object_id discovery_hash = (component, discovery_id) if discovery_payload: diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 5f89d0d79db..8cd0d177835 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -82,11 +82,13 @@ class NextBusDepartureSensor( def _log_debug(self, message, *args): """Log debug message with prefix.""" - _LOGGER.debug(":".join((self.agency, self.route, self.stop, message)), *args) + msg = f"{self.agency}:{self.route}:{self.stop}:{message}" + _LOGGER.debug(msg, *args) def _log_err(self, message, *args): """Log error message with prefix.""" - _LOGGER.error(":".join((self.agency, self.route, self.stop, message)), *args) + msg = f"{self.agency}:{self.route}:{self.stop}:{message}" + _LOGGER.error(msg, *args) async def async_added_to_hass(self) -> None: """Read data from coordinator after adding to hass.""" diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 6128272fbbb..a89c50a2210 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -62,7 +62,7 @@ def _normalize_ips_and_network(hosts_str: str) -> list[str] | None: start, end = host.split("-", 1) if "." not in end: ip_1, ip_2, ip_3, _ = start.split(".", 3) - end = ".".join([ip_1, ip_2, ip_3, end]) + end = f"{ip_1}.{ip_2}.{ip_3}.{end}" summarize_address_range(ip_address(start), ip_address(end)) except ValueError: pass diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index d4cce99e1cc..94a56bb1922 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -159,13 +159,9 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): ) ) - name_prefix = " ".join( - ( - "Opower", - self.api.utility.subdomain(), - account.meter_type.name.lower(), - account.utility_account_id, - ) + name_prefix = ( + f"Opower {self.api.utility.subdomain()} " + f"{account.meter_type.name.lower()} {account.utility_account_id}" ) cost_metadata = StatisticMetaData( has_mean=False, diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index aee25da507c..c90455de7ec 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -34,14 +34,8 @@ class WithingsFlowHandler( def extra_authorize_data(self) -> dict[str, str]: """Extra data that needs to be appended to the authorize url.""" return { - "scope": ",".join( - [ - AuthScope.USER_INFO, - AuthScope.USER_METRICS, - AuthScope.USER_ACTIVITY, - AuthScope.USER_SLEEP_EVENTS, - ] - ) + "scope": f"{AuthScope.USER_INFO},{AuthScope.USER_METRICS}," + f"{AuthScope.USER_ACTIVITY},{AuthScope.USER_SLEEP_EVENTS}" } async def async_step_reauth( diff --git a/pyproject.toml b/pyproject.toml index 8be7b9b40f3..5ea335115ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -679,6 +679,7 @@ select = [ "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) "E", # pycodestyle "F", # pyflakes/autoflake + "FLY", # flynt "G", # flake8-logging-format "I", # isort "INP", # flake8-no-pep420 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index c02ebd8de2e..c6c5907cdb9 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -32,7 +32,7 @@ HEADER: Final = """ GENERAL_SETTINGS: Final[dict[str, str]] = { "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), - "plugins": ", ".join(["pydantic.mypy"]), + "plugins": "pydantic.mypy", "show_error_codes": "true", "follow_imports": "silent", # Enable some checks globally. @@ -43,20 +43,8 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { "warn_redundant_casts": "true", "warn_unused_configs": "true", "warn_unused_ignores": "true", - "enable_error_code": ", ".join( - [ - "ignore-without-code", - "redundant-self", - "truthy-iterable", - ] - ), - "disable_error_code": ", ".join( - [ - "annotation-unchecked", - "import-not-found", - "import-untyped", - ] - ), + "enable_error_code": "ignore-without-code, redundant-self, truthy-iterable", + "disable_error_code": "annotation-unchecked, import-not-found, import-untyped", # Impractical in real code # E.g. this breaks passthrough ParamSpec typing with Concatenate "extra_checks": "false", diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 00cdc5ddbee..fb2d4e0a504 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -497,15 +497,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -564,15 +561,12 @@ async def test_if_fires_on_state_change_legacy( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index 93689b4f233..6837c882a01 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -275,8 +275,10 @@ async def test_if_state( "action": { "service": "test.automation", "data_template": { - "some": "is_on {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_on {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -294,8 +296,10 @@ async def test_if_state( "action": { "service": "test.automation", "data_template": { - "some": "is_off {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_off {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -359,8 +363,10 @@ async def test_if_state_legacy( "action": { "service": "test.automation", "data_template": { - "some": "is_on {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_on {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -421,9 +427,9 @@ async def test_if_fires_on_for_condition( "action": { "service": "test.automation", "data_template": { - "some": "is_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ("platform", "event.event_type") + "some": ( + "is_off {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" ) }, }, diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 76dcdb33993..dd55682fc8d 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -277,15 +277,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "bat_low {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "bat_low {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -301,15 +298,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "not_bat_low {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "not_bat_low {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -379,15 +373,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -453,15 +444,12 @@ async def test_if_fires_on_state_change_legacy( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index afd39fe6d8e..8e2f794f1e0 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -625,15 +625,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index e70f0144e6a..a3b982ab70e 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -219,7 +219,7 @@ async def test_services(hass: HomeAssistant) -> None: async def test_set_fan_speed(hass: HomeAssistant) -> None: """Test vacuum service to set the fan speed.""" - group_vacuums = ",".join([ENTITY_VACUUM_COMPLETE, ENTITY_VACUUM_MOST]) + group_vacuums = f"{ENTITY_VACUUM_COMPLETE},{ENTITY_VACUUM_MOST}" old_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) old_state_most = hass.states.get(ENTITY_VACUUM_MOST) @@ -239,7 +239,7 @@ async def test_set_fan_speed(hass: HomeAssistant) -> None: async def test_send_command(hass: HomeAssistant) -> None: """Test vacuum service to send a command.""" - group_vacuums = ",".join([ENTITY_VACUUM_COMPLETE]) + group_vacuums = f"{ENTITY_VACUUM_COMPLETE}" old_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) await common.async_send_command( diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index ac5e490b738..4526a9d9b67 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1446,8 +1446,10 @@ async def test_automation_with_sub_condition( "action": { "service": "test.automation", "data_template": { - "some": "and {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "and {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -1477,8 +1479,10 @@ async def test_automation_with_sub_condition( "action": { "service": "test.automation", "data_template": { - "some": "or {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "or {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index 44a29d4a9ba..a8850bf50b9 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -64,15 +64,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_on {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_on {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -88,15 +85,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -112,15 +106,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_on_or_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_on_or_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -187,15 +178,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index c121569184f..a217a5d89ec 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -385,15 +385,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index 85461d60aac..b8045ad495c 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -72,16 +72,13 @@ async def test_if_fires_on_zone_enter(hass: HomeAssistant, calls) -> None: "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "zone.name", - "id", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.zone.name }}" + " - {{ trigger.id }}" ) }, }, @@ -285,15 +282,12 @@ async def test_if_fires_on_zone_appear(hass: HomeAssistant, calls) -> None: "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "zone.name", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.zone.name }}" ) }, }, @@ -334,15 +328,12 @@ async def test_if_fires_on_zone_appear_2(hass: HomeAssistant, calls) -> None: "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "zone.name", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.zone.name }}" ) }, }, @@ -399,15 +390,12 @@ async def test_if_fires_on_zone_disappear(hass: HomeAssistant, calls) -> None: "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "zone.name", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.zone.name }}" ) }, }, diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index cf2e1938228..2e2dca5b57a 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -980,16 +980,13 @@ async def test_template_string(hass: HomeAssistant, calls, below) -> None: "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "below", - "above", - "from_state.state", - "to_state.state", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.below }}" + " - {{ trigger.above }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" ) }, }, @@ -1346,9 +1343,10 @@ async def test_wait_template_with_trigger(hass: HomeAssistant, calls, above) -> { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ("platform", "entity_id", "to_state.state") + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.to_state.state }}" ) }, }, diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index aaf228c06f8..597ef0ab1a5 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -55,16 +55,13 @@ async def test_if_fires_on_entity_change(hass: HomeAssistant, calls) -> None: "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - "id", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" + " - {{ trigger.id }}" ) }, }, @@ -114,16 +111,13 @@ async def test_if_fires_on_entity_change_uuid( "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - "id", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" + " - {{ trigger.id }}" ) }, }, @@ -1079,14 +1073,11 @@ async def test_wait_template_with_trigger(hass: HomeAssistant, calls) -> None: { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" ) }, }, diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 8c45080c786..95bf2530b2d 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -306,7 +306,7 @@ async def setup_test_component( config_entry, pairing = await setup_test_accessories(hass, [accessory], connection) entity = "testdevice" if suffix is None else f"testdevice_{suffix}" - return Helper(hass, ".".join((domain, entity)), pairing, accessory, config_entry) + return Helper(hass, f"{domain}.{entity}", pairing, accessory, config_entry) async def assert_devices_and_entities_created( diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index ad4ac78d064..14ed9fae5e0 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -187,8 +187,10 @@ async def test_if_state( "action": { "service": "test.automation", "data_template": { - "some": "is_on {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_on {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -206,8 +208,10 @@ async def test_if_state( "action": { "service": "test.automation", "data_template": { - "some": "is_off {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_off {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index e064e82a385..fd6441588c4 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -293,15 +293,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_on {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_on {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -317,15 +314,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -341,15 +335,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_on_or_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_on_or_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index caaa51e86fa..eeee8530085 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -219,8 +219,10 @@ async def test_if_state( "action": { "service": "test.automation", "data_template": { - "some": "is_on {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_on {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -238,8 +240,10 @@ async def test_if_state( "action": { "service": "test.automation", "data_template": { - "some": "is_off {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_off {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -302,8 +306,10 @@ async def test_if_state_legacy( "action": { "service": "test.automation", "data_template": { - "some": "is_on {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_on {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -367,9 +373,9 @@ async def test_if_fires_on_for_condition( "action": { "service": "test.automation", "data_template": { - "some": "is_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ("platform", "event.event_type") + "some": ( + "is_off {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" ) }, }, diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index ea1c1c66b21..c38ab14061f 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -23,6 +23,14 @@ from tests.common import ( async_mock_service, ) +DATA_TEMPLATE_ATTRIBUTES = ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" +) + @pytest.fixture(autouse=True, name="stub_blueprint_populate") def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @@ -212,16 +220,7 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_on {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) - ) + "some": "turn_on " + DATA_TEMPLATE_ATTRIBUTES }, }, }, @@ -236,16 +235,7 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) - ) + "some": "turn_off " + DATA_TEMPLATE_ATTRIBUTES }, }, }, @@ -260,16 +250,7 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_on_or_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) - ) + "some": "turn_on_or_off " + DATA_TEMPLATE_ATTRIBUTES }, }, }, @@ -332,16 +313,7 @@ async def test_if_fires_on_state_change_legacy( "action": { "service": "test.automation", "data_template": { - "some": "turn_on {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) - ) + "some": "turn_on " + DATA_TEMPLATE_ATTRIBUTES }, }, }, @@ -396,16 +368,7 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) - ) + "some": "turn_off " + DATA_TEMPLATE_ATTRIBUTES }, }, } diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 3f518143285..3ad992d4458 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -363,15 +363,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -388,15 +385,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_on {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_on {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -413,15 +407,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -438,15 +429,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_on {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_on {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index ab11683889d..4c507b4bd66 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -412,15 +412,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 672084d644d..55af74b3373 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -393,13 +393,7 @@ async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None: "type": "Bearer", "expires_in": 60, "expires_at": time() + 1000, - "scope": " ".join( - [ - "read_smokedetector", - "read_thermostat", - "write_thermostat", - ] - ), + "scope": "read_smokedetector read_thermostat write_thermostat", }, }, options={}, diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index edfa7c5adf9..4fd14e82990 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -217,8 +217,10 @@ async def test_if_state( "action": { "service": "test.automation", "data_template": { - "some": "is_on {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_on {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -236,8 +238,10 @@ async def test_if_state( "action": { "service": "test.automation", "data_template": { - "some": "is_off {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_off {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -300,8 +304,10 @@ async def test_if_state_legacy( "action": { "service": "test.automation", "data_template": { - "some": "is_on {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_on {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -361,9 +367,9 @@ async def test_if_fires_on_for_condition( "action": { "service": "test.automation", "data_template": { - "some": "is_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ("platform", "event.event_type") + "some": ( + "is_off {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" ) }, }, diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index 1f80843be9a..68f7215186f 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -212,15 +212,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_on {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_on {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -236,15 +233,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -260,15 +254,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_on_or_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_on_or_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -331,15 +322,12 @@ async def test_if_fires_on_state_change_legacy( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -395,15 +383,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 08de630f025..2a142633ab3 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -545,8 +545,10 @@ async def test_if_state_above( "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, } @@ -612,8 +614,10 @@ async def test_if_state_above_legacy( "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, } @@ -679,8 +683,10 @@ async def test_if_state_below( "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, } @@ -747,8 +753,10 @@ async def test_if_state_between( "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, } diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index bb7337c0144..49e00a927b4 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -496,15 +496,12 @@ async def test_if_fires_on_state_above( "action": { "service": "test.automation", "data_template": { - "some": "bat_low {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "bat_low {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -564,15 +561,12 @@ async def test_if_fires_on_state_below( "action": { "service": "test.automation", "data_template": { - "some": "bat_low {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "bat_low {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -633,15 +627,12 @@ async def test_if_fires_on_state_between( "action": { "service": "test.automation", "data_template": { - "some": "bat_low {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "bat_low {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -712,15 +703,12 @@ async def test_if_fires_on_state_legacy( "action": { "service": "test.automation", "data_template": { - "some": "bat_low {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "bat_low {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -781,15 +769,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 50e070a4f68..e315ea8cdcd 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -127,8 +127,11 @@ async def test_sunset_trigger_with_offset(hass: HomeAssistant, calls) -> None: "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event", "offset")) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.event }}" + " - {{ trigger.offset }}" + ) }, }, } diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index e351daf2a5b..cd0a67fa992 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -217,8 +217,10 @@ async def test_if_state( "action": { "service": "test.automation", "data_template": { - "some": "is_on {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_on {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -236,8 +238,10 @@ async def test_if_state( "action": { "service": "test.automation", "data_template": { - "some": "is_off {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_off {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -300,8 +304,10 @@ async def test_if_state_legacy( "action": { "service": "test.automation", "data_template": { - "some": "is_on {{ trigger.%s }}" - % "}} - {{ trigger.".join(("platform", "event.event_type")) + "some": ( + "is_on {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" + ) }, }, }, @@ -360,9 +366,9 @@ async def test_if_fires_on_for_condition( "action": { "service": "test.automation", "data_template": { - "some": "is_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ("platform", "event.event_type") + "some": ( + "is_off {{ trigger.platform }}" + " - {{ trigger.event.event_type }}" ) }, }, diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 58803b0c6ac..c528f982ebb 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -212,15 +212,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_on {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_on {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -236,15 +233,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -260,15 +254,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "turn_on_or_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_on_or_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -332,15 +323,12 @@ async def test_if_fires_on_state_change_legacy( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -397,15 +385,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index 0d7d765b988..0f95503c333 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -329,15 +329,12 @@ async def test_if_not_fires_because_fail( "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -430,15 +427,12 @@ async def test_if_fires_on_change_with_bad_template( { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -502,15 +496,12 @@ async def test_if_fires_on_change_with_for(hass: HomeAssistant, calls) -> None: "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -549,15 +540,12 @@ async def test_if_fires_on_change_with_for_advanced( "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -593,15 +581,12 @@ async def test_if_fires_on_change_with_for_0_advanced( "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index 1ffd295bbc9..6ece4f818d1 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -214,15 +214,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "update_available {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "update_available {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -238,15 +235,12 @@ async def test_if_fires_on_state_change( "action": { "service": "test.automation", "data_template": { - "some": "no_update {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "no_update {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -314,15 +308,12 @@ async def test_if_fires_on_state_change_legacy( "action": { "service": "test.automation", "data_template": { - "some": "no_update {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "no_update {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, @@ -383,15 +374,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index b2273d905c1..bae57b1941f 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -356,15 +356,12 @@ async def test_if_fires_on_state_change_with_for( "action": { "service": "test.automation", "data_template": { - "some": "turn_off {{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "for", - ) + "some": ( + "turn_off {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" ) }, }, diff --git a/tests/components/xbox/test_config_flow.py b/tests/components/xbox/test_config_flow.py index 5abf9ad25d9..e547909f946 100644 --- a/tests/components/xbox/test_config_flow.py +++ b/tests/components/xbox/test_config_flow.py @@ -55,7 +55,7 @@ async def test_full_flow( }, ) - scope = "+".join(["Xboxlive.signin", "Xboxlive.offline_access"]) + scope = "Xboxlive.signin+Xboxlive.offline_access" assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index 8987481f460..7e42f41f119 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -64,16 +64,13 @@ async def test_if_fires_on_zone_enter(hass: HomeAssistant, calls) -> None: "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "zone.name", - "id", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.zone.name }}" + " - {{ trigger.id }}" ) }, }, @@ -143,16 +140,13 @@ async def test_if_fires_on_zone_enter_uuid(hass: HomeAssistant, calls) -> None: "action": { "service": "test.automation", "data_template": { - "some": "{{ trigger.%s }}" - % "}} - {{ trigger.".join( - ( - "platform", - "entity_id", - "from_state.state", - "to_state.state", - "zone.name", - "id", - ) + "some": ( + "{{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.zone.name }}" + " - {{ trigger.id }}" ) }, }, diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index 8367eda76e8..72bb4dd5b67 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -42,9 +42,7 @@ async def test_list_user(hass: HomeAssistant, provider, capsys) -> None: captured = capsys.readouterr() - assert captured.out == "\n".join( - ["test-user", "second-user", "", "Total users: 2", ""] - ) + assert captured.out == "test-user\nsecond-user\n\nTotal users: 2\n" async def test_add_user( From f3a3e6821bd2dbc81b37dc9999f7d5c52ba35b06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Apr 2024 21:14:35 -1000 Subject: [PATCH 41/64] Switch imap push coordinator to use eager_start (#115454) When I turned on eager_start here the data would always end up being None because _async_update_data always returned None. To fix this it now returns the value from the push loop. It appears this race would happen in production so this may be a bugfix but since I do not use this integration it could use a second set of eyes --- homeassistant/components/imap/coordinator.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 94699ae5dd4..53d24044b53 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -443,23 +443,24 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): _LOGGER.debug("Connected to server %s using IMAP push", entry.data[CONF_SERVER]) super().__init__(hass, imap_client, entry, None) self._push_wait_task: asyncio.Task[None] | None = None + self.number_of_messages: int | None = None async def _async_update_data(self) -> int | None: """Update the number of unread emails.""" await self.async_start() - return None + return self.number_of_messages async def async_start(self) -> None: """Start coordinator.""" self._push_wait_task = self.hass.async_create_background_task( - self._async_wait_push_loop(), "Wait for IMAP data push", eager_start=False + self._async_wait_push_loop(), "Wait for IMAP data push" ) async def _async_wait_push_loop(self) -> None: """Wait for data push from server.""" while True: try: - number_of_messages = await self._async_fetch_number_of_messages() + self.number_of_messages = await self._async_fetch_number_of_messages() except InvalidAuth as ex: self.auth_errors += 1 await self._cleanup() @@ -489,7 +490,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): continue else: self.auth_errors = 0 - self.async_set_updated_data(number_of_messages) + self.async_set_updated_data(self.number_of_messages) try: idle: asyncio.Future = await self.imap_client.idle_start() await self.imap_client.wait_server_push() From a213de3db5524b065d56f9ba7a9d7d187ec6176b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 12 Apr 2024 10:40:17 +0200 Subject: [PATCH 42/64] Add service schema tests for notify entity platform (#115457) * Add service schema tests for notify entity platform * Use correct entity * Assert on exception value --- tests/components/notify/test_init.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/components/notify/test_init.py b/tests/components/notify/test_init.py index 26ed2ddc250..1f9ec81e36a 100644 --- a/tests/components/notify/test_init.py +++ b/tests/components/notify/test_init.py @@ -7,6 +7,7 @@ from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +import voluptuous as vol import yaml from homeassistant import config as hass_config @@ -120,6 +121,27 @@ async def test_send_message_service( await hass.async_block_till_done() entity.send_message_mock_calls.assert_called_once() + entity.send_message_mock_calls.reset_mock() + + # Test schema: `None` message fails + with pytest.raises(vol.Invalid) as exc: + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + {"entity_id": "notify.test", notify.ATTR_MESSAGE: None}, + ) + assert ( + str(exc.value) == "string value is None for dictionary value @ data['message']" + ) + entity.send_message_mock_calls.assert_not_called() + + # Test schema: No message fails + with pytest.raises(vol.Invalid) as exc: + await hass.services.async_call( + notify.DOMAIN, notify.SERVICE_SEND_MESSAGE, {"entity_id": "notify.test"} + ) + assert str(exc.value) == "required key not provided @ data['message']" + entity.send_message_mock_calls.assert_not_called() # Test unloading the entry succeeds assert await hass.config_entries.async_unload(config_entry.entry_id) From d59af22b6996b0ce83de2dd7aee2064b1f900dcf Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 12 Apr 2024 11:50:22 +0200 Subject: [PATCH 43/64] Update frontend to 20240404.2 (#115460) --- 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 028fb28f01b..d711314cabb 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240404.1"] + "requirements": ["home-assistant-frontend==20240404.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1150da9ceac..090271e028e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,7 +31,7 @@ habluetooth==2.4.2 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240404.1 +home-assistant-frontend==20240404.2 home-assistant-intents==2024.4.3 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4ee717dd0d0..53d108fdce1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1081,7 +1081,7 @@ hole==0.8.0 holidays==0.46 # homeassistant.components.frontend -home-assistant-frontend==20240404.1 +home-assistant-frontend==20240404.2 # homeassistant.components.conversation home-assistant-intents==2024.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 479449d92cf..ca80aa78e00 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -883,7 +883,7 @@ hole==0.8.0 holidays==0.46 # homeassistant.components.frontend -home-assistant-frontend==20240404.1 +home-assistant-frontend==20240404.2 # homeassistant.components.conversation home-assistant-intents==2024.4.3 From f70ce8abf9de96893ad7fb02582d113e299521a4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Apr 2024 13:11:24 +0200 Subject: [PATCH 44/64] Fix ci Python cache key (#115467) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4299f298122..7dd6f798eef 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -94,7 +94,7 @@ jobs: id: generate_python_cache_key run: >- echo "key=venv-${{ env.CACHE_VERSION }}-${{ - hashFiles('requirements_test.txt') }}-${{ + hashFiles('requirements_test.txt', 'requirements_test_pre_commit.txt') }}-${{ hashFiles('requirements_all.txt') }}-${{ hashFiles('homeassistant/package_constraints.txt') }}" >> $GITHUB_OUTPUT - name: Generate partial pre-commit restore key From 348e1df949bae31fd1d3f805d5e892bba608e120 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 12 Apr 2024 14:47:46 +0200 Subject: [PATCH 45/64] Add strict connection (#112387) Co-authored-by: Martin Hjelmare --- homeassistant/auth/__init__.py | 8 +- homeassistant/auth/session.py | 205 +++++++++++ homeassistant/components/auth/__init__.py | 32 +- homeassistant/components/hassio/ingress.py | 1 - homeassistant/components/http/__init__.py | 93 ++++- homeassistant/components/http/auth.py | 94 ++++- homeassistant/components/http/const.py | 9 + homeassistant/components/http/icons.json | 5 + homeassistant/components/http/services.yaml | 1 + homeassistant/components/http/session.py | 160 +++++++++ .../http/strict_connection_static_page.html | 46 +++ homeassistant/components/http/strings.json | 16 + homeassistant/package_constraints.txt | 1 + pyproject.toml | 1 + requirements.txt | 1 + tests/components/api/test_init.py | 2 +- tests/components/http/test_auth.py | 339 ++++++++++++++++-- tests/components/http/test_init.py | 79 ++++ tests/components/http/test_session.py | 107 ++++++ tests/components/stream/conftest.py | 20 +- .../components/websocket_api/test_commands.py | 2 +- tests/helpers/test_service.py | 27 +- tests/scripts/test_check_config.py | 2 + 23 files changed, 1187 insertions(+), 64 deletions(-) create mode 100644 homeassistant/auth/session.py create mode 100644 homeassistant/components/http/icons.json create mode 100644 homeassistant/components/http/services.yaml create mode 100644 homeassistant/components/http/session.py create mode 100644 homeassistant/components/http/strict_connection_static_page.html create mode 100644 homeassistant/components/http/strings.json create mode 100644 tests/components/http/test_session.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 969fcc3529e..2a9525181f6 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -28,6 +28,7 @@ from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN, REFRESH_TOKEN_EXPIRA from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .models import AuthFlowResult from .providers import AuthProvider, LoginFlow, auth_provider_from_config +from .session import SessionManager EVENT_USER_ADDED = "user_added" EVENT_USER_UPDATED = "user_updated" @@ -85,7 +86,7 @@ async def auth_manager_from_config( module_hash[module.id] = module manager = AuthManager(hass, store, provider_hash, module_hash) - manager.async_setup() + await manager.async_setup() return manager @@ -180,9 +181,9 @@ class AuthManager: self._remove_expired_job = HassJob( self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback ) + self.session = SessionManager(hass, self) - @callback - def async_setup(self) -> None: + async def async_setup(self) -> None: """Set up the auth manager.""" hass = self.hass hass.async_add_shutdown_job( @@ -191,6 +192,7 @@ class AuthManager: ) ) self._async_track_next_refresh_token_expiration() + await self.session.async_setup() @property def auth_providers(self) -> list[AuthProvider]: diff --git a/homeassistant/auth/session.py b/homeassistant/auth/session.py new file mode 100644 index 00000000000..88297b50d90 --- /dev/null +++ b/homeassistant/auth/session.py @@ -0,0 +1,205 @@ +"""Session auth module.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import secrets +from typing import TYPE_CHECKING, Final, TypedDict + +from aiohttp.web import Request +from aiohttp_session import Session, get_session, new_session +from cryptography.fernet import Fernet + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.storage import Store +from homeassistant.util import dt as dt_util + +from .models import RefreshToken + +if TYPE_CHECKING: + from . import AuthManager + + +TEMP_TIMEOUT = timedelta(minutes=5) +TEMP_TIMEOUT_SECONDS = TEMP_TIMEOUT.total_seconds() + +SESSION_ID = "id" +STORAGE_VERSION = 1 +STORAGE_KEY = "auth.session" + + +class StrictConnectionTempSessionData: + """Data for accessing unauthorized resources for a short period of time.""" + + __slots__ = ("cancel_remove", "absolute_expiry") + + def __init__(self, cancel_remove: CALLBACK_TYPE) -> None: + """Initialize the temp session data.""" + self.cancel_remove: Final[CALLBACK_TYPE] = cancel_remove + self.absolute_expiry: Final[datetime] = dt_util.utcnow() + TEMP_TIMEOUT + + +class StoreData(TypedDict): + """Data to store.""" + + unauthorized_sessions: dict[str, str] + key: str + + +class SessionManager: + """Session manager.""" + + def __init__(self, hass: HomeAssistant, auth: AuthManager) -> None: + """Initialize the strict connection manager.""" + self._auth = auth + self._hass = hass + self._temp_sessions: dict[str, StrictConnectionTempSessionData] = {} + self._strict_connection_sessions: dict[str, str] = {} + self._store = Store[StoreData]( + hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True + ) + self._key: str | None = None + self._refresh_token_revoke_callbacks: dict[str, CALLBACK_TYPE] = {} + + @property + def key(self) -> str: + """Return the encryption key.""" + if self._key is None: + self._key = Fernet.generate_key().decode() + self._async_schedule_save() + return self._key + + async def async_validate_request_for_strict_connection_session( + self, + request: Request, + ) -> bool: + """Check if a request has a valid strict connection session.""" + session = await get_session(request) + if session.new or session.empty: + return False + result = self.async_validate_strict_connection_session(session) + if result is False: + session.invalidate() + return result + + @callback + def async_validate_strict_connection_session( + self, + session: Session, + ) -> bool: + """Validate a strict connection session.""" + if not (session_id := session.get(SESSION_ID)): + return False + + if token_id := self._strict_connection_sessions.get(session_id): + if self._auth.async_get_refresh_token(token_id): + return True + # refresh token is invalid, delete entry + self._strict_connection_sessions.pop(session_id) + self._async_schedule_save() + + if data := self._temp_sessions.get(session_id): + if dt_util.utcnow() <= data.absolute_expiry: + return True + # session expired, delete entry + self._temp_sessions.pop(session_id).cancel_remove() + + return False + + @callback + def _async_register_revoke_token_callback(self, refresh_token_id: str) -> None: + """Register a callback to revoke all sessions for a refresh token.""" + if refresh_token_id in self._refresh_token_revoke_callbacks: + return + + @callback + def async_invalidate_auth_sessions() -> None: + """Invalidate all sessions for a refresh token.""" + self._strict_connection_sessions = { + session_id: token_id + for session_id, token_id in self._strict_connection_sessions.items() + if token_id != refresh_token_id + } + self._async_schedule_save() + + self._refresh_token_revoke_callbacks[refresh_token_id] = ( + self._auth.async_register_revoke_token_callback( + refresh_token_id, async_invalidate_auth_sessions + ) + ) + + async def async_create_session( + self, + request: Request, + refresh_token: RefreshToken, + ) -> None: + """Create new session for given refresh token. + + Caller needs to make sure that the refresh token is valid. + By creating a session, we are implicitly revoking all other + sessions for the given refresh token as there is one refresh + token per device/user case. + """ + self._strict_connection_sessions = { + session_id: token_id + for session_id, token_id in self._strict_connection_sessions.items() + if token_id != refresh_token.id + } + + self._async_register_revoke_token_callback(refresh_token.id) + session_id = await self._async_create_new_session(request) + self._strict_connection_sessions[session_id] = refresh_token.id + self._async_schedule_save() + + async def async_create_temp_unauthorized_session(self, request: Request) -> None: + """Create a temporary unauthorized session.""" + session_id = await self._async_create_new_session( + request, max_age=int(TEMP_TIMEOUT_SECONDS) + ) + + @callback + def remove(_: datetime) -> None: + self._temp_sessions.pop(session_id, None) + + self._temp_sessions[session_id] = StrictConnectionTempSessionData( + async_call_later(self._hass, TEMP_TIMEOUT_SECONDS, remove) + ) + + async def _async_create_new_session( + self, + request: Request, + *, + max_age: int | None = None, + ) -> str: + session_id = secrets.token_hex(64) + + session = await new_session(request) + session[SESSION_ID] = session_id + if max_age is not None: + session.max_age = max_age + return session_id + + @callback + def _async_schedule_save(self, delay: float = 1) -> None: + """Save sessions.""" + self._store.async_delay_save(self._data_to_save, delay) + + @callback + def _data_to_save(self) -> StoreData: + """Return the data to store.""" + return StoreData( + unauthorized_sessions=self._strict_connection_sessions, + key=self.key, + ) + + async def async_setup(self) -> None: + """Set up session manager.""" + data = await self._store.async_load() + if data is None: + return + + self._key = data["key"] + self._strict_connection_sessions = data["unauthorized_sessions"] + for token_id in self._strict_connection_sessions.values(): + self._async_register_revoke_token_callback(token_id) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index ff54971eb64..3d825cd99b5 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -162,6 +162,7 @@ from homeassistant.util import dt as dt_util from . import indieauth, login_flow, mfa_setup_flow DOMAIN = "auth" +STRICT_CONNECTION_URL = "/auth/strict_connection/temp_token" StoreResultType = Callable[[str, Credentials], str] RetrieveResultType = Callable[[str, str], Credentials | None] @@ -187,6 +188,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.http.register_view(RevokeTokenView()) hass.http.register_view(LinkUserView(retrieve_result)) hass.http.register_view(OAuth2AuthorizeCallbackView()) + hass.http.register_view(StrictConnectionTempTokenView()) websocket_api.async_register_command(hass, websocket_current_user) websocket_api.async_register_command(hass, websocket_create_long_lived_access_token) @@ -260,10 +262,10 @@ class TokenView(HomeAssistantView): return await RevokeTokenView.post(self, request) # type: ignore[arg-type] if grant_type == "authorization_code": - return await self._async_handle_auth_code(hass, data, request.remote) + return await self._async_handle_auth_code(hass, data, request) if grant_type == "refresh_token": - return await self._async_handle_refresh_token(hass, data, request.remote) + return await self._async_handle_refresh_token(hass, data, request) return self.json( {"error": "unsupported_grant_type"}, status_code=HTTPStatus.BAD_REQUEST @@ -273,7 +275,7 @@ class TokenView(HomeAssistantView): self, hass: HomeAssistant, data: MultiDictProxy[str], - remote_addr: str | None, + request: web.Request, ) -> web.Response: """Handle authorization code request.""" client_id = data.get("client_id") @@ -313,7 +315,7 @@ class TokenView(HomeAssistantView): ) try: access_token = hass.auth.async_create_access_token( - refresh_token, remote_addr + refresh_token, request.remote ) except InvalidAuthError as exc: return self.json( @@ -321,6 +323,7 @@ class TokenView(HomeAssistantView): status_code=HTTPStatus.FORBIDDEN, ) + await hass.auth.session.async_create_session(request, refresh_token) return self.json( { "access_token": access_token, @@ -341,9 +344,9 @@ class TokenView(HomeAssistantView): self, hass: HomeAssistant, data: MultiDictProxy[str], - remote_addr: str | None, + request: web.Request, ) -> web.Response: - """Handle authorization code request.""" + """Handle refresh token request.""" client_id = data.get("client_id") if client_id is not None and not indieauth.verify_client_id(client_id): return self.json( @@ -381,7 +384,7 @@ class TokenView(HomeAssistantView): try: access_token = hass.auth.async_create_access_token( - refresh_token, remote_addr + refresh_token, request.remote ) except InvalidAuthError as exc: return self.json( @@ -389,6 +392,7 @@ class TokenView(HomeAssistantView): status_code=HTTPStatus.FORBIDDEN, ) + await hass.auth.session.async_create_session(request, refresh_token) return self.json( { "access_token": access_token, @@ -437,6 +441,20 @@ class LinkUserView(HomeAssistantView): return self.json_message("User linked") +class StrictConnectionTempTokenView(HomeAssistantView): + """View to get temporary strict connection token.""" + + url = STRICT_CONNECTION_URL + name = "api:auth:strict_connection:temp_token" + requires_auth = False + + async def get(self, request: web.Request) -> web.Response: + """Get a temporary token and redirect to main page.""" + hass = request.app[KEY_HASS] + await hass.auth.session.async_create_temp_unauthorized_session(request) + raise web.HTTPSeeOther(location="/") + + @callback def _create_auth_code_store() -> tuple[StoreResultType, RetrieveResultType]: """Create an in memory store.""" diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 6d6faa6fe75..ed6e47145dd 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -197,7 +197,6 @@ class HassIOIngress(HomeAssistantView): content_type or simple_response.content_type ): simple_response.enable_compression() - await simple_response.prepare(request) return simple_response # Stream response diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index e89031cb265..3e5f7333cbc 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -10,7 +10,8 @@ import os import socket import ssl from tempfile import NamedTemporaryFile -from typing import Any, Final, TypedDict, cast +from typing import Any, Final, Required, TypedDict, cast +from urllib.parse import quote_plus, urljoin from aiohttp import web from aiohttp.abc import AbstractStreamWriter @@ -30,8 +31,20 @@ from yarl import URL from homeassistant.components.network import async_get_source_ip from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT -from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import ( + Event, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceValidationError, + Unauthorized, + UnknownUser, +) from homeassistant.helpers import storage import homeassistant.helpers.config_validation as cv from homeassistant.helpers.http import ( @@ -53,9 +66,13 @@ from homeassistant.util import dt as dt_util, ssl as ssl_util from homeassistant.util.async_ import create_eager_task from homeassistant.util.json import json_loads -from .auth import async_setup_auth +from .auth import async_setup_auth, async_sign_path from .ban import setup_bans -from .const import KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401 +from .const import ( # noqa: F401 + KEY_HASS_REFRESH_TOKEN_ID, + KEY_HASS_USER, + StrictConnectionMode, +) from .cors import setup_cors from .decorators import require_admin # noqa: F401 from .forwarded import async_setup_forwarded @@ -80,6 +97,7 @@ CONF_TRUSTED_PROXIES: Final = "trusted_proxies" CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold" CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled" CONF_SSL_PROFILE: Final = "ssl_profile" +CONF_STRICT_CONNECTION: Final = "strict_connection" SSL_MODERN: Final = "modern" SSL_INTERMEDIATE: Final = "intermediate" @@ -129,6 +147,9 @@ HTTP_SCHEMA: Final = vol.All( [SSL_INTERMEDIATE, SSL_MODERN] ), vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, + vol.Optional( + CONF_STRICT_CONNECTION, default=StrictConnectionMode.DISABLED + ): vol.In([e.value for e in StrictConnectionMode]), } ), ) @@ -152,6 +173,7 @@ class ConfData(TypedDict, total=False): login_attempts_threshold: int ip_ban_enabled: bool ssl_profile: str + strict_connection: Required[StrictConnectionMode] @bind_hass @@ -218,6 +240,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: login_threshold=login_threshold, is_ban_enabled=is_ban_enabled, use_x_frame_options=use_x_frame_options, + strict_connection_non_cloud=conf[CONF_STRICT_CONNECTION], ) async def stop_server(event: Event) -> None: @@ -247,6 +270,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: local_ip, host, server_port, ssl_certificate is not None ) + _setup_services(hass, conf) return True @@ -331,6 +355,7 @@ class HomeAssistantHTTP: login_threshold: int, is_ban_enabled: bool, use_x_frame_options: bool, + strict_connection_non_cloud: StrictConnectionMode, ) -> None: """Initialize the server.""" self.app[KEY_HASS] = self.hass @@ -347,7 +372,7 @@ class HomeAssistantHTTP: if is_ban_enabled: setup_bans(self.hass, self.app, login_threshold) - await async_setup_auth(self.hass, self.app) + await async_setup_auth(self.hass, self.app, strict_connection_non_cloud) setup_headers(self.app, use_x_frame_options) setup_cors(self.app, cors_origins) @@ -577,3 +602,59 @@ async def start_http_server_and_save_config( ] store.async_delay_save(lambda: conf, SAVE_DELAY) + + +@callback +def _setup_services(hass: HomeAssistant, conf: ConfData) -> None: + """Set up services for HTTP component.""" + + async def create_temporary_strict_connection_url( + call: ServiceCall, + ) -> ServiceResponse: + """Create a strict connection url and return it.""" + # Copied form homeassistant/helpers/service.py#_async_admin_handler + # as the helper supports no responses yet + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser(context=call.context) + if not user.is_admin: + raise Unauthorized(context=call.context) + + if conf[CONF_STRICT_CONNECTION] is StrictConnectionMode.DISABLED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="strict_connection_not_enabled_non_cloud", + ) + + try: + url = get_url(hass, prefer_external=True, allow_internal=False) + except NoURLAvailableError as ex: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_external_url_available", + ) from ex + + # to avoid circular import + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.auth import STRICT_CONNECTION_URL + + path = async_sign_path( + hass, + STRICT_CONNECTION_URL, + datetime.timedelta(hours=1), + use_content_user=True, + ) + url = urljoin(url, path) + + return { + "url": f"https://login.home-assistant.io?u={quote_plus(url)}", + "direct_url": url, + } + + hass.services.async_register( + DOMAIN, + "create_temporary_strict_connection_url", + create_temporary_strict_connection_url, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 2073c998384..1eb74289089 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -4,14 +4,18 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from datetime import timedelta +from http import HTTPStatus from ipaddress import ip_address import logging +import os import secrets import time from typing import Any, Final from aiohttp import hdrs -from aiohttp.web import Application, Request, StreamResponse, middleware +from aiohttp.web import Application, Request, Response, StreamResponse, middleware +from aiohttp.web_exceptions import HTTPBadRequest +from aiohttp_session import session_middleware import jwt from jwt import api_jws from yarl import URL @@ -27,7 +31,13 @@ from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.storage import Store from homeassistant.util.network import is_local -from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER +from .const import ( + KEY_AUTHENTICATED, + KEY_HASS_REFRESH_TOKEN_ID, + KEY_HASS_USER, + StrictConnectionMode, +) +from .session import HomeAssistantCookieStorage _LOGGER = logging.getLogger(__name__) @@ -39,6 +49,10 @@ SAFE_QUERY_PARAMS: Final = ["height", "width"] STORAGE_VERSION = 1 STORAGE_KEY = "http.auth" CONTENT_USER_NAME = "Home Assistant Content" +STRICT_CONNECTION_EXCLUDED_PATH = "/api/webhook/" +STRICT_CONNECTION_STATIC_PAGE = os.path.join( + os.path.dirname(__file__), "strict_connection_static_page.html" +) @callback @@ -48,13 +62,16 @@ def async_sign_path( expiration: timedelta, *, refresh_token_id: str | None = None, + use_content_user: bool = False, ) -> str: """Sign a path for temporary access without auth header.""" if (secret := hass.data.get(DATA_SIGN_SECRET)) is None: secret = hass.data[DATA_SIGN_SECRET] = secrets.token_hex() if refresh_token_id is None: - if connection := websocket_api.current_connection.get(): + if use_content_user: + refresh_token_id = hass.data[STORAGE_KEY] + elif connection := websocket_api.current_connection.get(): refresh_token_id = connection.refresh_token_id elif ( request := current_request.get() @@ -114,7 +131,11 @@ def async_user_not_allowed_do_auth( return "User cannot authenticate remotely" -async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: +async def async_setup_auth( + hass: HomeAssistant, + app: Application, + strict_connection_mode_non_cloud: StrictConnectionMode, +) -> None: """Create auth middleware for the app.""" store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) if (data := await store.async_load()) is None: @@ -135,6 +156,16 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: await store.async_save(data) hass.data[STORAGE_KEY] = refresh_token.id + strict_connection_static_file_content = None + if strict_connection_mode_non_cloud is StrictConnectionMode.STATIC_PAGE: + + def read_static_page() -> str: + with open(STRICT_CONNECTION_STATIC_PAGE, encoding="utf-8") as file: + return file.read() + + strict_connection_static_file_content = await hass.async_add_executor_job( + read_static_page + ) @callback def async_validate_auth_header(request: Request) -> bool: @@ -224,6 +255,22 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: authenticated = True auth_type = "signed request" + if ( + not authenticated + and strict_connection_mode_non_cloud is not StrictConnectionMode.DISABLED + and not request.path.startswith(STRICT_CONNECTION_EXCLUDED_PATH) + and not await hass.auth.session.async_validate_request_for_strict_connection_session( + request + ) + and ( + resp := _async_perform_action_on_non_local( + request, strict_connection_static_file_content + ) + ) + is not None + ): + return resp + if authenticated and _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "Authenticated %s for %s using %s", @@ -235,4 +282,43 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: request[KEY_AUTHENTICATED] = authenticated return await handler(request) + app.middlewares.append(session_middleware(HomeAssistantCookieStorage(hass))) app.middlewares.append(auth_middleware) + + +@callback +def _async_perform_action_on_non_local( + request: Request, + strict_connection_static_file_content: str | None, +) -> StreamResponse | None: + """Perform strict connection mode action if the request is not local. + + The function does the following: + - Try to get the IP address of the request. If it fails, assume it's not local + - If the request is local, return None (allow the request to continue) + - If strict_connection_static_file_content is set, return a response with the content + - Otherwise close the connection and raise an exception + """ + try: + ip_address_ = ip_address(request.remote) # type: ignore[arg-type] + except ValueError: + _LOGGER.debug("Invalid IP address: %s", request.remote) + ip_address_ = None + + if ip_address_ and is_local(ip_address_): + return None + + _LOGGER.debug("Perform strict connection action for %s", ip_address_) + if strict_connection_static_file_content: + return Response( + text=strict_connection_static_file_content, + content_type="text/html", + status=HTTPStatus.IM_A_TEAPOT, + ) + + if transport := request.transport: + # it should never happen that we don't have a transport + transport.close() + + # We need to raise an exception to stop processing the request + raise HTTPBadRequest diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index 1254744f258..d02416c531b 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,8 +1,17 @@ """HTTP specific constants.""" +from enum import StrEnum from typing import Final from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401 KEY_HASS_USER: Final = "hass_user" KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" + + +class StrictConnectionMode(StrEnum): + """Enum for strict connection mode.""" + + DISABLED = "disabled" + STATIC_PAGE = "static_page" + DROP_CONNECTION = "drop_connection" diff --git a/homeassistant/components/http/icons.json b/homeassistant/components/http/icons.json new file mode 100644 index 00000000000..8e8b6285db7 --- /dev/null +++ b/homeassistant/components/http/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "create_temporary_strict_connection_url": "mdi:login-variant" + } +} diff --git a/homeassistant/components/http/services.yaml b/homeassistant/components/http/services.yaml new file mode 100644 index 00000000000..16b0debb144 --- /dev/null +++ b/homeassistant/components/http/services.yaml @@ -0,0 +1 @@ +create_temporary_strict_connection_url: ~ diff --git a/homeassistant/components/http/session.py b/homeassistant/components/http/session.py new file mode 100644 index 00000000000..81668ec2ccc --- /dev/null +++ b/homeassistant/components/http/session.py @@ -0,0 +1,160 @@ +"""Session http module.""" + +from functools import lru_cache +import logging + +from aiohttp.web import Request, StreamResponse +from aiohttp_session import Session, SessionData +from aiohttp_session.cookie_storage import EncryptedCookieStorage +from cryptography.fernet import InvalidToken + +from homeassistant.auth.const import REFRESH_TOKEN_EXPIRATION +from homeassistant.core import HomeAssistant +from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.network import is_cloud_connection +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads + +from .ban import process_wrong_login + +_LOGGER = logging.getLogger(__name__) + +COOKIE_NAME = "SC" +PREFIXED_COOKIE_NAME = f"__Host-{COOKIE_NAME}" +SESSION_CACHE_SIZE = 16 + + +def _get_cookie_name(is_secure: bool) -> str: + """Return the cookie name.""" + return PREFIXED_COOKIE_NAME if is_secure else COOKIE_NAME + + +class HomeAssistantCookieStorage(EncryptedCookieStorage): + """Home Assistant cookie storage. + + Own class is required: + - to set the secure flag based on the connection type + - to use a LRU cache for session decryption + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the cookie storage.""" + super().__init__( + hass.auth.session.key, + cookie_name=PREFIXED_COOKIE_NAME, + max_age=int(REFRESH_TOKEN_EXPIRATION), + httponly=True, + samesite="Lax", + secure=True, + encoder=json_dumps, + decoder=json_loads, + ) + self._hass = hass + + def _secure_connection(self, request: Request) -> bool: + """Return if the connection is secure (https).""" + return is_cloud_connection(self._hass) or request.secure + + def load_cookie(self, request: Request) -> str | None: + """Load cookie.""" + is_secure = self._secure_connection(request) + cookie_name = _get_cookie_name(is_secure) + return request.cookies.get(cookie_name) + + @lru_cache(maxsize=SESSION_CACHE_SIZE) + def _decrypt_cookie(self, cookie: str) -> Session | None: + """Decrypt and validate cookie.""" + try: + data = SessionData( # type: ignore[misc] + self._decoder( + self._fernet.decrypt( + cookie.encode("utf-8"), ttl=self.max_age + ).decode("utf-8") + ) + ) + except (InvalidToken, TypeError, ValueError, *JSON_DECODE_EXCEPTIONS): + _LOGGER.warning("Cannot decrypt/parse cookie value") + return None + + session = Session(None, data=data, new=data is None, max_age=self.max_age) + + # Validate session if not empty + if ( + not session.empty + and not self._hass.auth.session.async_validate_strict_connection_session( + session + ) + ): + # Invalidate session as it is not valid + session.invalidate() + + return session + + async def new_session(self) -> Session: + """Create a new session and mark it as changed.""" + session = Session(None, data=None, new=True, max_age=self.max_age) + session.changed() + return session + + async def load_session(self, request: Request) -> Session: + """Load session.""" + # Split parent function to use lru_cache + if (cookie := self.load_cookie(request)) is None: + return await self.new_session() + + if (session := self._decrypt_cookie(cookie)) is None: + # Decrypting/parsing failed, log wrong login and create a new session + await process_wrong_login(request) + session = await self.new_session() + + return session + + async def save_session( + self, request: Request, response: StreamResponse, session: Session + ) -> None: + """Save session.""" + + is_secure = self._secure_connection(request) + cookie_name = _get_cookie_name(is_secure) + + if session.empty: + response.del_cookie(cookie_name) + else: + params = self.cookie_params.copy() + params["secure"] = is_secure + params["max_age"] = session.max_age + + cookie_data = self._encoder(self._get_session_data(session)).encode("utf-8") + response.set_cookie( + cookie_name, + self._fernet.encrypt(cookie_data).decode("utf-8"), + **params, + ) + # Add Cache-Control header to not cache the cookie as it + # is used for session management + self._add_cache_control_header(response) + + @staticmethod + def _add_cache_control_header(response: StreamResponse) -> None: + """Add/set cache control header to no-cache="Set-Cookie".""" + # Structure of the Cache-Control header defined in + # https://datatracker.ietf.org/doc/html/rfc2068#section-14.9 + if header := response.headers.get("Cache-Control"): + directives = [] + for directive in header.split(","): + directive = directive.strip() + directive_lowered = directive.lower() + if directive_lowered.startswith("no-cache"): + if "set-cookie" in directive_lowered or directive.find("=") == -1: + # Set-Cookie is already in the no-cache directive or + # the whole request should not be cached -> Nothing to do + return + + # Add Set-Cookie to the no-cache + # [:-1] to remove the " at the end of the directive + directive = f"{directive[:-1]}, Set-Cookie" + + directives.append(directive) + header = ", ".join(directives) + else: + header = 'no-cache="Set-Cookie"' + response.headers["Cache-Control"] = header diff --git a/homeassistant/components/http/strict_connection_static_page.html b/homeassistant/components/http/strict_connection_static_page.html new file mode 100644 index 00000000000..24049d9a0eb --- /dev/null +++ b/homeassistant/components/http/strict_connection_static_page.html @@ -0,0 +1,46 @@ + + + + + + I'm a Teapot + + + +
+

Error 418: I'm a Teapot

+

+ Oops! Looks like the server is taking a coffee break.
+ Don't worry, it'll be back to brewing your requests in no time! +

+

+
+ + diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json new file mode 100644 index 00000000000..7cd64f5f297 --- /dev/null +++ b/homeassistant/components/http/strings.json @@ -0,0 +1,16 @@ +{ + "exceptions": { + "strict_connection_not_enabled_non_cloud": { + "message": "Strict connection is not enabled for non-cloud requests" + }, + "no_external_url_available": { + "message": "No external URL available" + } + }, + "services": { + "create_temporary_strict_connection_url": { + "name": "Create a temporary strict connection URL", + "description": "Create a temporary strict connection URL, which can be used to login on another device." + } + } +} diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 090271e028e..b253d600a2d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -7,6 +7,7 @@ aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.3.1 aiohttp==3.9.4 aiohttp_cors==0.7.0 +aiohttp_session==2.12.0 astral==2.2 async-interrupt==1.1.1 async-upnp-client==0.38.3 diff --git a/pyproject.toml b/pyproject.toml index 5ea335115ca..79a66cc7d82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "aiodns==3.2.0", "aiohttp==3.9.4", "aiohttp_cors==0.7.0", + "aiohttp_session==2.12.0", "aiohttp-fast-url-dispatcher==0.3.0", "aiohttp-zlib-ng==0.3.1", "astral==2.2", diff --git a/requirements.txt b/requirements.txt index 3cd1e8edfa5..f2f26f9bb54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ aiodns==3.2.0 aiohttp==3.9.4 aiohttp_cors==0.7.0 +aiohttp_session==2.12.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.3.1 astral==2.2 diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 0ac2e5973fe..5443d48452f 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -306,7 +306,7 @@ async def test_api_get_services( for serv_domain in data: local = local_services.pop(serv_domain["domain"]) - assert serv_domain["services"] == local + assert serv_domain["services"].keys() == local.keys() async def test_api_call_service_no_data( diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index de6f323bc8a..f0f87e58173 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,22 +1,28 @@ """The tests for the Home Assistant HTTP component.""" +from collections.abc import Awaitable, Callable from datetime import timedelta from http import HTTPStatus from ipaddress import ip_network +import logging from unittest.mock import Mock, patch -from aiohttp import BasicAuth, web +from aiohttp import BasicAuth, ServerDisconnectedError, web +from aiohttp.test_utils import TestClient from aiohttp.web_exceptions import HTTPUnauthorized +from aiohttp_session import get_session import jwt import pytest import yarl +from yarl import URL from homeassistant.auth.const import GROUP_ID_READ_ONLY -from homeassistant.auth.models import User +from homeassistant.auth.models import RefreshToken, User from homeassistant.auth.providers import trusted_networks from homeassistant.auth.providers.legacy_api_password import ( LegacyApiPasswordAuthProvider, ) +from homeassistant.auth.session import SESSION_ID, TEMP_TIMEOUT from homeassistant.components import websocket_api from homeassistant.components.http import KEY_HASS from homeassistant.components.http.auth import ( @@ -24,11 +30,12 @@ from homeassistant.components.http.auth import ( DATA_SIGN_SECRET, SIGN_QUERY_PARAM, STORAGE_KEY, + STRICT_CONNECTION_STATIC_PAGE, async_setup_auth, async_sign_path, async_user_not_allowed_do_auth, ) -from homeassistant.components.http.const import KEY_AUTHENTICATED +from homeassistant.components.http.const import KEY_AUTHENTICATED, StrictConnectionMode from homeassistant.components.http.forwarded import async_setup_forwarded from homeassistant.components.http.request_context import ( current_request, @@ -36,13 +43,15 @@ from homeassistant.components.http.request_context import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from . import HTTP_HEADER_HA_AUTH -from tests.common import MockUser +from tests.common import MockUser, async_fire_time_changed from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator, WebSocketGenerator +_LOGGER = logging.getLogger(__name__) API_PASSWORD = "test-password" # Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases @@ -54,7 +63,13 @@ TRUSTED_NETWORKS = [ ] TRUSTED_ADDRESSES = ["100.64.0.1", "192.0.2.100", "FD01:DB8::1", "2001:DB8:ABCD::1"] EXTERNAL_ADDRESSES = ["198.51.100.1", "2001:DB8:FA1::1"] -UNTRUSTED_ADDRESSES = [*EXTERNAL_ADDRESSES, "127.0.0.1", "::1"] +LOCALHOST_ADDRESSES = ["127.0.0.1", "::1"] +UNTRUSTED_ADDRESSES = [*EXTERNAL_ADDRESSES, *LOCALHOST_ADDRESSES] +PRIVATE_ADDRESSES = [ + "192.168.10.10", + "172.16.4.20", + "10.100.50.5", +] async def mock_handler(request): @@ -122,7 +137,7 @@ async def test_cant_access_with_password_in_header( hass: HomeAssistant, ) -> None: """Test access with password in header.""" - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) client = await aiohttp_client(app) req = await client.get("/", headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) @@ -139,7 +154,7 @@ async def test_cant_access_with_password_in_query( hass: HomeAssistant, ) -> None: """Test access with password in URL.""" - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) client = await aiohttp_client(app) resp = await client.get("/", params={"api_password": API_PASSWORD}) @@ -159,7 +174,7 @@ async def test_basic_auth_does_not_work( legacy_auth: LegacyApiPasswordAuthProvider, ) -> None: """Test access with basic authentication.""" - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) client = await aiohttp_client(app) req = await client.get("/", auth=BasicAuth("homeassistant", API_PASSWORD)) @@ -183,7 +198,7 @@ async def test_cannot_access_with_trusted_ip( hass_owner_user: MockUser, ) -> None: """Test access with an untrusted ip address.""" - await async_setup_auth(hass, app2) + await async_setup_auth(hass, app2, StrictConnectionMode.DISABLED) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -211,7 +226,7 @@ async def test_auth_active_access_with_access_token_in_header( ) -> None: """Test access with access token in header.""" token = hass_access_token - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -247,7 +262,7 @@ async def test_auth_active_access_with_trusted_ip( hass_owner_user: MockUser, ) -> None: """Test access with an untrusted ip address.""" - await async_setup_auth(hass, app2) + await async_setup_auth(hass, app2, StrictConnectionMode.DISABLED) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -274,7 +289,7 @@ async def test_auth_legacy_support_api_password_cannot_access( hass: HomeAssistant, ) -> None: """Test access using api_password if auth.support_legacy.""" - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) client = await aiohttp_client(app) req = await client.get("/", headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) @@ -296,7 +311,7 @@ async def test_auth_access_signed_path_with_refresh_token( """Test access with signed url.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -341,7 +356,7 @@ async def test_auth_access_signed_path_with_query_param( """Test access with signed url and query params.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -371,7 +386,7 @@ async def test_auth_access_signed_path_with_query_param_order( """Test access with signed url and query params different order.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -412,7 +427,7 @@ async def test_auth_access_signed_path_with_query_param_safe_param( """Test access with signed url and changing a safe param.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -451,7 +466,7 @@ async def test_auth_access_signed_path_with_query_param_tamper( """Test access with signed url and query params that have been tampered with.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -520,7 +535,7 @@ async def test_auth_access_signed_path_with_http( ) app.router.add_get("/hello", mock_handler) - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -544,7 +559,7 @@ async def test_auth_access_signed_path_with_content_user( hass: HomeAssistant, app, aiohttp_client: ClientSessionGenerator ) -> None: """Test access signed url uses content user.""" - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) signed_path = async_sign_path(hass, "/", timedelta(seconds=5)) signature = yarl.URL(signed_path).query["authSig"] claims = jwt.decode( @@ -564,7 +579,7 @@ async def test_local_only_user_rejected( ) -> None: """Test access with access token in header.""" token = hass_access_token - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) set_mock_ip = mock_real_ip(app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -630,7 +645,7 @@ async def test_create_user_once(hass: HomeAssistant) -> None: """Test that we reuse the user.""" cur_users = len(await hass.auth.async_get_users()) app = web.Application() - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) users = await hass.auth.async_get_users() assert len(users) == cur_users + 1 @@ -642,7 +657,287 @@ async def test_create_user_once(hass: HomeAssistant) -> None: assert len(user.refresh_tokens) == 1 assert user.system_generated - await async_setup_auth(hass, app) + await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) # test it did not create a user assert len(await hass.auth.async_get_users()) == cur_users + 1 + + +@pytest.fixture +def app_strict_connection(hass): + """Fixture to set up a web.Application.""" + + async def handler(request): + """Return if request was authenticated.""" + return web.json_response(data={"authenticated": request[KEY_AUTHENTICATED]}) + + app = web.Application() + app[KEY_HASS] = hass + app.router.add_get("/", handler) + async_setup_forwarded(app, True, []) + return app + + +@pytest.mark.parametrize( + "strict_connection_mode", [e.value for e in StrictConnectionMode] +) +async def test_strict_connection_non_cloud_authenticated_requests( + hass: HomeAssistant, + app_strict_connection: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, + strict_connection_mode: StrictConnectionMode, +) -> None: + """Test authenticated requests with strict connection.""" + token = hass_access_token + await async_setup_auth(hass, app_strict_connection, strict_connection_mode) + set_mock_ip = mock_real_ip(app_strict_connection) + client = await aiohttp_client(app_strict_connection) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + assert refresh_token + assert hass.auth.session._strict_connection_sessions == {} + + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + for remote_addr in (*LOCALHOST_ADDRESSES, *PRIVATE_ADDRESSES, *EXTERNAL_ADDRESSES): + set_mock_ip(remote_addr) + + # authorized requests should work normally + req = await client.get("/", headers={"Authorization": f"Bearer {token}"}) + assert req.status == HTTPStatus.OK + assert await req.json() == {"authenticated": True} + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + assert await req.json() == {"authenticated": True} + + +@pytest.mark.parametrize( + "strict_connection_mode", [e.value for e in StrictConnectionMode] +) +async def test_strict_connection_non_cloud_local_unauthenticated_requests( + hass: HomeAssistant, + app_strict_connection: web.Application, + aiohttp_client: ClientSessionGenerator, + strict_connection_mode: StrictConnectionMode, +) -> None: + """Test local unauthenticated requests with strict connection.""" + await async_setup_auth(hass, app_strict_connection, strict_connection_mode) + set_mock_ip = mock_real_ip(app_strict_connection) + client = await aiohttp_client(app_strict_connection) + assert hass.auth.session._strict_connection_sessions == {} + + for remote_addr in (*LOCALHOST_ADDRESSES, *PRIVATE_ADDRESSES): + set_mock_ip(remote_addr) + # local requests should work normally + req = await client.get("/") + assert req.status == HTTPStatus.OK + assert await req.json() == {"authenticated": False} + + +def _add_set_cookie_endpoint(app: web.Application, refresh_token: RefreshToken) -> None: + """Add an endpoint to set a cookie.""" + + async def set_cookie(request: web.Request) -> web.Response: + hass = request.app[KEY_HASS] + # Clear all sessions + hass.auth.session._temp_sessions.clear() + hass.auth.session._strict_connection_sessions.clear() + + if request.query["token"] == "refresh": + await hass.auth.session.async_create_session(request, refresh_token) + else: + await hass.auth.session.async_create_temp_unauthorized_session(request) + session = await get_session(request) + return web.Response(text=session[SESSION_ID]) + + app.router.add_get("/test/cookie", set_cookie) + + +async def _test_strict_connection_non_cloud_enabled_setup( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, + strict_connection_mode: StrictConnectionMode, +) -> tuple[TestClient, Callable[[str], None], RefreshToken]: + """Test external unauthenticated requests with strict connection non cloud enabled.""" + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + assert refresh_token + session = hass.auth.session + assert session._strict_connection_sessions == {} + assert session._temp_sessions == {} + + _add_set_cookie_endpoint(app, refresh_token) + await async_setup_auth(hass, app, strict_connection_mode) + set_mock_ip = mock_real_ip(app) + client = await aiohttp_client(app) + return (client, set_mock_ip, refresh_token) + + +async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, + perform_unauthenticated_request: Callable[ + [HomeAssistant, TestClient], Awaitable[None] + ], + strict_connection_mode: StrictConnectionMode, +) -> None: + """Test external unauthenticated requests with strict connection non cloud enabled.""" + client, set_mock_ip, _ = await _test_strict_connection_non_cloud_enabled_setup( + hass, app, aiohttp_client, hass_access_token, strict_connection_mode + ) + + for remote_addr in EXTERNAL_ADDRESSES: + set_mock_ip(remote_addr) + await perform_unauthenticated_request(hass, client) + + +async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_refresh_token( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, + perform_unauthenticated_request: Callable[ + [HomeAssistant, TestClient], Awaitable[None] + ], + strict_connection_mode: StrictConnectionMode, +) -> None: + """Test external unauthenticated requests with strict connection non cloud enabled and refresh token cookie.""" + ( + client, + set_mock_ip, + refresh_token, + ) = await _test_strict_connection_non_cloud_enabled_setup( + hass, app, aiohttp_client, hass_access_token, strict_connection_mode + ) + session = hass.auth.session + + # set strict connection cookie with refresh token + set_mock_ip(LOCALHOST_ADDRESSES[0]) + session_id = await (await client.get("/test/cookie?token=refresh")).text() + assert session._strict_connection_sessions == {session_id: refresh_token.id} + for remote_addr in EXTERNAL_ADDRESSES: + set_mock_ip(remote_addr) + req = await client.get("/") + assert req.status == HTTPStatus.OK + assert await req.json() == {"authenticated": False} + + # Invalidate refresh token, which should also invalidate session + hass.auth.async_remove_refresh_token(refresh_token) + assert session._strict_connection_sessions == {} + for remote_addr in EXTERNAL_ADDRESSES: + set_mock_ip(remote_addr) + await perform_unauthenticated_request(hass, client) + + +async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_temp_session( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, + perform_unauthenticated_request: Callable[ + [HomeAssistant, TestClient], Awaitable[None] + ], + strict_connection_mode: StrictConnectionMode, +) -> None: + """Test external unauthenticated requests with strict connection non cloud enabled and temp cookie.""" + client, set_mock_ip, _ = await _test_strict_connection_non_cloud_enabled_setup( + hass, app, aiohttp_client, hass_access_token, strict_connection_mode + ) + session = hass.auth.session + + # set strict connection cookie with temp session + assert session._temp_sessions == {} + set_mock_ip(LOCALHOST_ADDRESSES[0]) + session_id = await (await client.get("/test/cookie?token=temp")).text() + assert client.session.cookie_jar.filter_cookies(URL("http://127.0.0.1")) + assert session_id in session._temp_sessions + for remote_addr in EXTERNAL_ADDRESSES: + set_mock_ip(remote_addr) + resp = await client.get("/") + assert resp.status == HTTPStatus.OK + assert await resp.json() == {"authenticated": False} + + async_fire_time_changed(hass, utcnow() + TEMP_TIMEOUT + timedelta(minutes=1)) + await hass.async_block_till_done(wait_background_tasks=True) + + assert session._temp_sessions == {} + for remote_addr in EXTERNAL_ADDRESSES: + set_mock_ip(remote_addr) + await perform_unauthenticated_request(hass, client) + + +async def _drop_connection_unauthorized_request( + _: HomeAssistant, client: TestClient +) -> None: + with pytest.raises(ServerDisconnectedError): + # unauthorized requests should raise ServerDisconnectedError + await client.get("/") + + +async def _static_page_unauthorized_request( + hass: HomeAssistant, client: TestClient +) -> None: + req = await client.get("/") + assert req.status == HTTPStatus.IM_A_TEAPOT + + def read_static_page() -> str: + with open(STRICT_CONNECTION_STATIC_PAGE, encoding="utf-8") as file: + return file.read() + + assert await req.text() == await hass.async_add_executor_job(read_static_page) + + +@pytest.mark.parametrize( + "test_func", + [ + _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests, + _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_refresh_token, + _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_temp_session, + ], + ids=[ + "no cookie", + "refresh token cookie", + "temp session cookie", + ], +) +@pytest.mark.parametrize( + ("strict_connection_mode", "request_func"), + [ + (StrictConnectionMode.DROP_CONNECTION, _drop_connection_unauthorized_request), + (StrictConnectionMode.STATIC_PAGE, _static_page_unauthorized_request), + ], + ids=["drop connection", "static page"], +) +async def test_strict_connection_non_cloud_external_unauthenticated_requests( + hass: HomeAssistant, + app_strict_connection: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, + test_func: Callable[ + [ + HomeAssistant, + web.Application, + ClientSessionGenerator, + str, + Callable[[HomeAssistant, TestClient], Awaitable[None]], + StrictConnectionMode, + ], + Awaitable[None], + ], + strict_connection_mode: StrictConnectionMode, + request_func: Callable[[HomeAssistant, TestClient], Awaitable[None]], +) -> None: + """Test external unauthenticated requests with strict connection non cloud.""" + await test_func( + hass, + app_strict_connection, + aiohttp_client, + hass_access_token, + request_func, + strict_connection_mode, + ) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 9e892e2ee43..b84da595ab1 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -7,6 +7,7 @@ from ipaddress import ip_network import logging from pathlib import Path from unittest.mock import Mock, patch +from urllib.parse import quote_plus import pytest @@ -14,7 +15,10 @@ from homeassistant.auth.providers.legacy_api_password import ( LegacyApiPasswordAuthProvider, ) from homeassistant.components import http +from homeassistant.components.http.const import StrictConnectionMode +from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.http import KEY_HASS from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component @@ -521,3 +525,78 @@ async def test_logging( response = await client.get("/api/states/logging.entity") assert response.status == HTTPStatus.OK assert "GET /api/states/logging.entity" not in caplog.text + + +async def test_service_create_temporary_strict_connection_url_strict_connection_disabled( + hass: HomeAssistant, +) -> None: + """Test service create_temporary_strict_connection_url with strict_connection not enabled.""" + assert await async_setup_component(hass, http.DOMAIN, {"http": {}}) + with pytest.raises( + ServiceValidationError, + match="Strict connection is not enabled for non-cloud requests", + ): + await hass.services.async_call( + http.DOMAIN, + "create_temporary_strict_connection_url", + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize( + ("mode"), + [ + StrictConnectionMode.DROP_CONNECTION, + StrictConnectionMode.STATIC_PAGE, + ], +) +async def test_service_create_temporary_strict_connection( + hass: HomeAssistant, mode: StrictConnectionMode +) -> None: + """Test service create_temporary_strict_connection_url.""" + assert await async_setup_component( + hass, http.DOMAIN, {"http": {"strict_connection": mode}} + ) + + # No external url set + assert hass.config.external_url is None + assert hass.config.internal_url is None + with pytest.raises(ServiceValidationError, match="No external URL available"): + await hass.services.async_call( + http.DOMAIN, + "create_temporary_strict_connection_url", + blocking=True, + return_response=True, + ) + + # Raise if only internal url is available + hass.config.api = Mock(use_ssl=False, port=8123, local_ip="192.168.123.123") + with pytest.raises(ServiceValidationError, match="No external URL available"): + await hass.services.async_call( + http.DOMAIN, + "create_temporary_strict_connection_url", + blocking=True, + return_response=True, + ) + + # Set external url too + external_url = "https://example.com" + await async_process_ha_core_config( + hass, + {"external_url": external_url}, + ) + assert hass.config.external_url == external_url + response = await hass.services.async_call( + http.DOMAIN, + "create_temporary_strict_connection_url", + blocking=True, + return_response=True, + ) + assert isinstance(response, dict) + direct_url_prefix = f"{external_url}/auth/strict_connection/temp_token?authSig=" + assert response.pop("direct_url").startswith(direct_url_prefix) + assert response.pop("url").startswith( + f"https://login.home-assistant.io?u={quote_plus(direct_url_prefix)}" + ) + assert response == {} # No more keys in response diff --git a/tests/components/http/test_session.py b/tests/components/http/test_session.py new file mode 100644 index 00000000000..ae62365749a --- /dev/null +++ b/tests/components/http/test_session.py @@ -0,0 +1,107 @@ +"""Tests for HTTP session.""" + +from collections.abc import Callable +import logging +from typing import Any +from unittest.mock import patch + +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +import pytest + +from homeassistant.auth.session import SESSION_ID +from homeassistant.components.http.session import ( + COOKIE_NAME, + HomeAssistantCookieStorage, +) +from homeassistant.core import HomeAssistant + + +def fake_request_with_strict_connection_cookie(cookie_value: str) -> web.Request: + """Return a fake request with a strict connection cookie.""" + request = make_mocked_request( + "GET", "/", headers={"Cookie": f"{COOKIE_NAME}={cookie_value}"} + ) + assert COOKIE_NAME in request.cookies + return request + + +@pytest.fixture +def cookie_storage(hass: HomeAssistant) -> HomeAssistantCookieStorage: + """Fixture for the cookie storage.""" + return HomeAssistantCookieStorage(hass) + + +def _encrypt_cookie_data(cookie_storage: HomeAssistantCookieStorage, data: Any) -> str: + """Encrypt cookie data.""" + cookie_data = cookie_storage._encoder(data).encode("utf-8") + return cookie_storage._fernet.encrypt(cookie_data).decode("utf-8") + + +@pytest.mark.parametrize( + "func", + [ + lambda _: "invalid", + lambda storage: _encrypt_cookie_data(storage, "bla"), + lambda storage: _encrypt_cookie_data(storage, None), + ], +) +async def test_load_session_modified_cookies( + cookie_storage: HomeAssistantCookieStorage, + caplog: pytest.LogCaptureFixture, + func: Callable[[HomeAssistantCookieStorage], str], +) -> None: + """Test that on modified cookies the session is empty and the request will be logged for ban.""" + request = fake_request_with_strict_connection_cookie(func(cookie_storage)) + with patch( + "homeassistant.components.http.session.process_wrong_login", + ) as mock_process_wrong_login: + session = await cookie_storage.load_session(request) + assert session.empty + assert ( + "homeassistant.components.http.session", + logging.WARNING, + "Cannot decrypt/parse cookie value", + ) in caplog.record_tuples + mock_process_wrong_login.assert_called() + + +async def test_load_session_validate_session( + hass: HomeAssistant, + cookie_storage: HomeAssistantCookieStorage, +) -> None: + """Test load session validates the session.""" + session = await cookie_storage.new_session() + session[SESSION_ID] = "bla" + request = fake_request_with_strict_connection_cookie( + _encrypt_cookie_data(cookie_storage, cookie_storage._get_session_data(session)) + ) + + with patch.object( + hass.auth.session, "async_validate_strict_connection_session", return_value=True + ) as mock_validate: + session = await cookie_storage.load_session(request) + assert not session.empty + assert session[SESSION_ID] == "bla" + mock_validate.assert_called_with(session) + + # verify lru_cache is working + mock_validate.reset_mock() + await cookie_storage.load_session(request) + mock_validate.assert_not_called() + + session = await cookie_storage.new_session() + session[SESSION_ID] = "something" + request = fake_request_with_strict_connection_cookie( + _encrypt_cookie_data(cookie_storage, cookie_storage._get_session_data(session)) + ) + + with patch.object( + hass.auth.session, + "async_validate_strict_connection_session", + return_value=False, + ): + session = await cookie_storage.load_session(request) + assert session.empty + assert SESSION_ID not in session + assert session._changed diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index 9ce23d99152..280d15cd1ef 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -14,7 +14,6 @@ from __future__ import annotations import asyncio from collections.abc import Generator -from http import HTTPStatus import logging import threading from unittest.mock import Mock, patch @@ -87,6 +86,17 @@ class HLSSync: self._num_recvs = 0 self._num_finished = 0 + def on_resp(): + self._num_finished += 1 + self.check_requests_ready() + + class SyncResponse(web.Response): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + on_resp() + + self.response = SyncResponse + def reset_request_pool(self, num_requests: int, reset_finished=True): """Use to reset the request counter between segments.""" self._num_recvs = 0 @@ -120,12 +130,6 @@ class HLSSync: self.check_requests_ready() return self._original_not_found() - def response(self, body, headers=None, status=HTTPStatus.OK): - """Intercept the Response call so we know when the web handler is finished.""" - self._num_finished += 1 - self.check_requests_ready() - return self._original_response(body=body, headers=headers, status=status) - async def recv(self, output: StreamOutput, **kw): """Intercept the recv call so we know when the response is blocking on recv.""" self._num_recvs += 1 @@ -164,7 +168,7 @@ def hls_sync(): ), patch( "homeassistant.components.stream.hls.web.Response", - side_effect=sync.response, + new=sync.response, ), ): yield sync diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index e96f1c4f903..2bd76accfdd 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -701,7 +701,7 @@ async def test_get_services( assert msg["id"] == id_ assert msg["type"] == const.TYPE_RESULT assert msg["success"] - assert msg["result"] == hass.services.async_services() + assert msg["result"].keys() == hass.services.async_services().keys() async def test_get_config( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 74b8a86ce7c..b5e71f4c9d8 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -7,6 +7,7 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from pytest_unordered import unordered import voluptuous as vol # To prevent circular import when running just this file @@ -16,6 +17,7 @@ import homeassistant.components # noqa: F401 from homeassistant.components.group import DOMAIN as DOMAIN_GROUP, Group from homeassistant.components.logger import DOMAIN as DOMAIN_LOGGER from homeassistant.components.shell_command import DOMAIN as DOMAIN_SHELL_COMMAND +from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH from homeassistant.const import ( ATTR_ENTITY_ID, ENTITY_MATCH_ALL, @@ -785,7 +787,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: """Test async_get_all_descriptions.""" group_config = {DOMAIN_GROUP: {}} assert await async_setup_component(hass, DOMAIN_GROUP, group_config) - assert await async_setup_component(hass, "system_health", {}) + assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {}) with patch( "homeassistant.helpers.service._load_services_files", @@ -795,17 +797,20 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: # Test we only load services.yaml for integrations with services.yaml # And system_health has no services - assert proxy_load_services_files.mock_calls[0][1][1] == [ - await async_get_integration(hass, "group") - ] + assert proxy_load_services_files.mock_calls[0][1][1] == unordered( + [ + await async_get_integration(hass, DOMAIN_GROUP), + await async_get_integration(hass, "http"), # system_health requires http + ] + ) - assert len(descriptions) == 1 - - assert "description" in descriptions["group"]["reload"] - assert "fields" in descriptions["group"]["reload"] + assert len(descriptions) == 2 + assert DOMAIN_GROUP in descriptions + assert "description" in descriptions[DOMAIN_GROUP]["reload"] + assert "fields" in descriptions[DOMAIN_GROUP]["reload"] # Does not have services - assert "system_health" not in descriptions + assert DOMAIN_SYSTEM_HEALTH not in descriptions logger_config = {DOMAIN_LOGGER: {}} @@ -833,8 +838,8 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: await async_setup_component(hass, DOMAIN_LOGGER, logger_config) descriptions = await service.async_get_all_descriptions(hass) - assert len(descriptions) == 2 - + assert len(descriptions) == 3 + assert DOMAIN_LOGGER in descriptions assert descriptions[DOMAIN_LOGGER]["set_default_level"]["name"] == "Translated name" assert ( descriptions[DOMAIN_LOGGER]["set_default_level"]["description"] diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 79c64259f8b..76acb2ff678 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from homeassistant.components.http.const import StrictConnectionMode from homeassistant.config import YAML_CONFIG_FILE from homeassistant.scripts import check_config @@ -134,6 +135,7 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: "login_attempts_threshold": -1, "server_port": 8123, "ssl_profile": "modern", + "strict_connection": StrictConnectionMode.DISABLED, "use_x_frame_options": True, "server_host": ["0.0.0.0", "::"], } From 6d22dd073c9ceb0abf92e66fbe9236eafe7e8afa Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 12 Apr 2024 16:03:23 +0200 Subject: [PATCH 46/64] Bump ruff to 0.3.7 (#115451) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- requirements_test_pre_commit.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 760e7e20676..326346a9e81 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.5 + rev: v0.3.7 hooks: - id: ruff args: diff --git a/pyproject.toml b/pyproject.toml index 79a66cc7d82..92b3649d3e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -659,7 +659,7 @@ filterwarnings = [ ] [tool.ruff] -required-version = ">=0.3.4" +required-version = ">=0.3.7" [tool.ruff.lint] select = [ diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index dacdb752a8d..46ade953da2 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.2.6 -ruff==0.3.5 +ruff==0.3.7 yamllint==1.35.1 From bea4c52d107ee9f9e5ad19f9734cae4b4d16f84f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 12 Apr 2024 16:33:05 +0200 Subject: [PATCH 47/64] Ignore coverage for aiohttp_resolver backport helper (#115177) * Ignore coverage for aiohttp_resolver backport helper * Adjust generate to sort core items * Adjust validate to sort core items * Split line * Apply suggestion Co-authored-by: Dave T <17680170+davet2001@users.noreply.github.com> * Fix suggestion --------- Co-authored-by: Dave T <17680170+davet2001@users.noreply.github.com> --- .coveragerc | 3 +- script/hassfest/coverage.py | 85 ++++++++++++++++++++----------------- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/.coveragerc b/.coveragerc index c02a6fefe75..ceff3384202 100644 --- a/.coveragerc +++ b/.coveragerc @@ -6,11 +6,12 @@ source = homeassistant omit = homeassistant/__main__.py + homeassistant/helpers/backports/aiohttp_resolver.py homeassistant/helpers/signal.py homeassistant/scripts/__init__.py + homeassistant/scripts/benchmark/__init__.py homeassistant/scripts/check_config.py homeassistant/scripts/ensure_config.py - homeassistant/scripts/benchmark/__init__.py homeassistant/scripts/macos/__init__.py # omit pieces of code that rely on external devices being present diff --git a/script/hassfest/coverage.py b/script/hassfest/coverage.py index 264960a42e1..686a6697e49 100644 --- a/script/hassfest/coverage.py +++ b/script/hassfest/coverage.py @@ -20,24 +20,17 @@ DONT_IGNORE = ( "scene.py", ) -PREFIX = """# Sorted by hassfest. +CORE_PREFIX = """# Sorted by hassfest. # # To sort, run python3 -m script.hassfest -p coverage [run] source = homeassistant omit = - homeassistant/__main__.py - homeassistant/helpers/signal.py - homeassistant/scripts/__init__.py - homeassistant/scripts/check_config.py - homeassistant/scripts/ensure_config.py - homeassistant/scripts/benchmark/__init__.py - homeassistant/scripts/macos/__init__.py - - # omit pieces of code that rely on external devices being present """ - +COMPONENTS_PREFIX = ( + " # omit pieces of code that rely on external devices being present\n" +) SUFFIX = """[report] # Regexes for lines to exclude from consideration exclude_lines = @@ -62,6 +55,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: coverage_path = config.root / ".coveragerc" not_found: list[str] = [] + unsorted: list[str] = [] checking = False previous_line = "" @@ -69,6 +63,10 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: for line in fp: line = line.strip() + if line == COMPONENTS_PREFIX.strip(): + previous_line = "" + continue + if not line or line.startswith("#"): continue @@ -92,27 +90,21 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: not_found.append(line) continue + if line < previous_line: + unsorted.append(line) + previous_line = line + if not line.startswith("homeassistant/components/"): continue - integration_path = path.parent - while len(integration_path.parts) > 3: - integration_path = integration_path.parent - - integration = integrations[integration_path.name] - - # Ensure sorted - if line < previous_line: - integration.add_error( - "coverage", - f"{line} is unsorted in .coveragerc file", - ) - previous_line = line - - # Ignore sub-directories for further checks + # Ignore sub-directories if len(path.parts) > 4: continue + integration_path = path.parent + + integration = integrations[integration_path.name] + if ( path.parts[-1] == "*" and Path(f"tests/components/{integration.domain}/__init__.py").exists() @@ -132,6 +124,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: f"{check} must not be ignored by the .coveragerc file", ) + if unsorted: + config.add_error( + "coverage", + "Paths are unsorted in .coveragerc file. " + "Run python3 -m script.hassfest\n - " + f"{'\n - '.join(unsorted)}", + fixable=True, + ) + if not_found: raise RuntimeError( f".coveragerc references files that don't exist: {', '.join(not_found)}." @@ -141,23 +142,31 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: def generate(integrations: dict[str, Integration], config: Config) -> None: """Sort coverage.""" coverage_path = config.root / ".coveragerc" - lines = [] - start = False + core = [] + components = [] + section = "header" with coverage_path.open("rt") as fp: for line in fp: - if ( - not start - and line - == " # omit pieces of code that rely on external devices being present\n" - ): - start = True - elif line == "[report]\n": + if line == "[report]\n": break - elif start and line != "\n": - lines.append(line) - content = f"{PREFIX}{"".join(sorted(lines))}\n\n{SUFFIX}" + if section != "core" and line == "omit =\n": + section = "core" + elif section != "components" and line == COMPONENTS_PREFIX: + section = "components" + elif section == "core" and line != "\n": + core.append(line) + elif section == "components" and line != "\n": + components.append(line) + + assert core, "core should be a non-empty list" + assert components, "components should be a non-empty list" + content = ( + f"{CORE_PREFIX}{"".join(sorted(core))}\n" + f"{COMPONENTS_PREFIX}{"".join(sorted(components))}\n" + f"\n{SUFFIX}" + ) with coverage_path.open("w") as fp: fp.write(content) From b266224ccdb1c773e390c69f500c4d07461f63c4 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 13 Apr 2024 04:27:38 +1000 Subject: [PATCH 48/64] Add diagnostics to Teslemetry (#115195) * Add diag * Add diag and tests * Fix redaction * Add another energy redact * Review Feedback * Update snapshot * Fixed the wrong integration * Fix snapshot again * Update tests/components/teslemetry/test_diagnostics.py --------- Co-authored-by: G Johansson --- .../components/teslemetry/diagnostics.py | 46 +++ .../snapshots/test_diagnostics.ambr | 295 ++++++++++++++++++ .../components/teslemetry/test_diagnostics.py | 23 ++ 3 files changed, 364 insertions(+) create mode 100644 homeassistant/components/teslemetry/diagnostics.py create mode 100644 tests/components/teslemetry/snapshots/test_diagnostics.ambr create mode 100644 tests/components/teslemetry/test_diagnostics.py diff --git a/homeassistant/components/teslemetry/diagnostics.py b/homeassistant/components/teslemetry/diagnostics.py new file mode 100644 index 00000000000..f8a8e6727a7 --- /dev/null +++ b/homeassistant/components/teslemetry/diagnostics.py @@ -0,0 +1,46 @@ +"""Provides diagnostics for Teslemetry.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +VEHICLE_REDACT = [ + "id", + "user_id", + "vehicle_id", + "vin", + "tokens", + "id_s", + "drive_state_active_route_latitude", + "drive_state_active_route_longitude", + "drive_state_latitude", + "drive_state_longitude", + "drive_state_native_latitude", + "drive_state_native_longitude", +] + +ENERGY_REDACT = ["vin"] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + vehicles = [ + x.coordinator.data for x in hass.data[DOMAIN][config_entry.entry_id].vehicles + ] + energysites = [ + x.coordinator.data for x in hass.data[DOMAIN][config_entry.entry_id].energysites + ] + + # Return only the relevant children + return { + "vehicles": async_redact_data(vehicles, VEHICLE_REDACT), + "energysites": async_redact_data(energysites, ENERGY_REDACT), + } diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..74eff27c4a0 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -0,0 +1,295 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'energysites': list([ + dict({ + 'backup_capable': True, + 'battery_power': 5060, + 'energy_left': 38896.47368421053, + 'generator_power': 0, + 'grid_power': 0, + 'grid_services_active': False, + 'grid_services_power': 0, + 'grid_status': 'Active', + 'island_status': 'on_grid', + 'load_power': 6245, + 'percentage_charged': 95.50537403739663, + 'solar_power': 1185, + 'storm_mode_active': False, + 'timestamp': '2024-01-01T00:00:00+00:00', + 'total_pack_energy': 40727, + 'wall_connectors': dict({ + 'abd-123': dict({ + 'din': 'abd-123', + 'wall_connector_fault_state': 2, + 'wall_connector_power': 0, + 'wall_connector_state': 2, + }), + 'bcd-234': dict({ + 'din': 'bcd-234', + 'wall_connector_fault_state': 2, + 'wall_connector_power': 0, + 'wall_connector_state': 2, + }), + }), + }), + ]), + 'vehicles': list([ + dict({ + 'access_type': 'OWNER', + 'api_version': 71, + 'backseat_token': None, + 'backseat_token_updated_at': None, + 'ble_autopair_enrolled': False, + 'calendar_enabled': True, + 'charge_state_battery_heater_on': False, + 'charge_state_battery_level': 77, + 'charge_state_battery_range': 266.87, + 'charge_state_charge_amps': 16, + 'charge_state_charge_current_request': 16, + 'charge_state_charge_current_request_max': 16, + 'charge_state_charge_enable_request': True, + 'charge_state_charge_energy_added': 0, + 'charge_state_charge_limit_soc': 80, + 'charge_state_charge_limit_soc_max': 100, + 'charge_state_charge_limit_soc_min': 50, + 'charge_state_charge_limit_soc_std': 80, + 'charge_state_charge_miles_added_ideal': 0, + 'charge_state_charge_miles_added_rated': 0, + 'charge_state_charge_port_cold_weather_mode': False, + 'charge_state_charge_port_color': '', + 'charge_state_charge_port_door_open': True, + 'charge_state_charge_port_latch': 'Engaged', + 'charge_state_charge_rate': 0, + 'charge_state_charger_actual_current': 0, + 'charge_state_charger_phases': None, + 'charge_state_charger_pilot_current': 16, + 'charge_state_charger_power': 0, + 'charge_state_charger_voltage': 2, + 'charge_state_charging_state': 'Stopped', + 'charge_state_conn_charge_cable': 'IEC', + 'charge_state_est_battery_range': 275.04, + 'charge_state_fast_charger_brand': '', + 'charge_state_fast_charger_present': False, + 'charge_state_fast_charger_type': 'ACSingleWireCAN', + 'charge_state_ideal_battery_range': 266.87, + 'charge_state_max_range_charge_counter': 0, + 'charge_state_minutes_to_full_charge': 0, + 'charge_state_not_enough_power_to_heat': None, + 'charge_state_off_peak_charging_enabled': False, + 'charge_state_off_peak_charging_times': 'all_week', + 'charge_state_off_peak_hours_end_time': 900, + 'charge_state_preconditioning_enabled': False, + 'charge_state_preconditioning_times': 'all_week', + 'charge_state_scheduled_charging_mode': 'Off', + 'charge_state_scheduled_charging_pending': False, + 'charge_state_scheduled_charging_start_time': None, + 'charge_state_scheduled_charging_start_time_app': 600, + 'charge_state_scheduled_departure_time': 1704837600, + 'charge_state_scheduled_departure_time_minutes': 480, + 'charge_state_supercharger_session_trip_planner': False, + 'charge_state_time_to_full_charge': 0, + 'charge_state_timestamp': 1705707520649, + 'charge_state_trip_charging': False, + 'charge_state_usable_battery_level': 77, + 'charge_state_user_charge_enable_request': None, + 'climate_state_allow_cabin_overheat_protection': True, + 'climate_state_auto_seat_climate_left': False, + 'climate_state_auto_seat_climate_right': True, + 'climate_state_auto_steering_wheel_heat': False, + 'climate_state_battery_heater': False, + 'climate_state_battery_heater_no_power': None, + 'climate_state_cabin_overheat_protection': 'On', + 'climate_state_cabin_overheat_protection_actively_cooling': False, + 'climate_state_climate_keeper_mode': 'off', + 'climate_state_cop_activation_temperature': 'High', + 'climate_state_defrost_mode': 0, + 'climate_state_driver_temp_setting': 22, + 'climate_state_fan_status': 0, + 'climate_state_hvac_auto_request': 'On', + 'climate_state_inside_temp': 29.8, + 'climate_state_is_auto_conditioning_on': False, + 'climate_state_is_climate_on': False, + 'climate_state_is_front_defroster_on': False, + 'climate_state_is_preconditioning': False, + 'climate_state_is_rear_defroster_on': False, + 'climate_state_left_temp_direction': 251, + 'climate_state_max_avail_temp': 28, + 'climate_state_min_avail_temp': 15, + 'climate_state_outside_temp': 30, + 'climate_state_passenger_temp_setting': 22, + 'climate_state_remote_heater_control_enabled': False, + 'climate_state_right_temp_direction': 251, + 'climate_state_seat_heater_left': 0, + 'climate_state_seat_heater_rear_center': 0, + 'climate_state_seat_heater_rear_left': 0, + 'climate_state_seat_heater_rear_right': 0, + 'climate_state_seat_heater_right': 0, + 'climate_state_side_mirror_heaters': False, + 'climate_state_steering_wheel_heat_level': 0, + 'climate_state_steering_wheel_heater': False, + 'climate_state_supports_fan_only_cabin_overheat_protection': True, + 'climate_state_timestamp': 1705707520649, + 'climate_state_wiper_blade_heater': False, + 'color': None, + 'drive_state_active_route_latitude': '**REDACTED**', + 'drive_state_active_route_longitude': '**REDACTED**', + 'drive_state_active_route_miles_to_arrival': 0.039491, + 'drive_state_active_route_minutes_to_arrival': 0.103577, + 'drive_state_active_route_traffic_minutes_delay': 0, + 'drive_state_gps_as_of': 1701129612, + 'drive_state_heading': 185, + 'drive_state_latitude': '**REDACTED**', + 'drive_state_longitude': '**REDACTED**', + 'drive_state_native_latitude': '**REDACTED**', + 'drive_state_native_location_supported': 1, + 'drive_state_native_longitude': '**REDACTED**', + 'drive_state_native_type': 'wgs', + 'drive_state_power': -7, + 'drive_state_shift_state': None, + 'drive_state_speed': None, + 'drive_state_timestamp': 1705707520649, + 'granular_access_hide_private': False, + 'gui_settings_gui_24_hour_time': False, + 'gui_settings_gui_charge_rate_units': 'kW', + 'gui_settings_gui_distance_units': 'km/hr', + 'gui_settings_gui_range_display': 'Rated', + 'gui_settings_gui_temperature_units': 'C', + 'gui_settings_gui_tirepressure_units': 'Psi', + 'gui_settings_show_range_units': False, + 'gui_settings_timestamp': 1705707520649, + 'id': '**REDACTED**', + 'id_s': '**REDACTED**', + 'in_service': False, + 'state': 'online', + 'tokens': '**REDACTED**', + 'user_id': '**REDACTED**', + 'vehicle_config_aux_park_lamps': 'Eu', + 'vehicle_config_badge_version': 1, + 'vehicle_config_can_accept_navigation_requests': True, + 'vehicle_config_can_actuate_trunks': True, + 'vehicle_config_car_special_type': 'base', + 'vehicle_config_car_type': 'model3', + 'vehicle_config_charge_port_type': 'CCS', + 'vehicle_config_cop_user_set_temp_supported': False, + 'vehicle_config_dashcam_clip_save_supported': True, + 'vehicle_config_default_charge_to_max': False, + 'vehicle_config_driver_assist': 'TeslaAP3', + 'vehicle_config_ece_restrictions': False, + 'vehicle_config_efficiency_package': 'M32021', + 'vehicle_config_eu_vehicle': True, + 'vehicle_config_exterior_color': 'DeepBlue', + 'vehicle_config_exterior_trim': 'Black', + 'vehicle_config_exterior_trim_override': '', + 'vehicle_config_has_air_suspension': False, + 'vehicle_config_has_ludicrous_mode': False, + 'vehicle_config_has_seat_cooling': False, + 'vehicle_config_headlamp_type': 'Global', + 'vehicle_config_interior_trim_type': 'White2', + 'vehicle_config_key_version': 2, + 'vehicle_config_motorized_charge_port': True, + 'vehicle_config_paint_color_override': '0,9,25,0.7,0.04', + 'vehicle_config_performance_package': 'Base', + 'vehicle_config_plg': True, + 'vehicle_config_pws': True, + 'vehicle_config_rear_drive_unit': 'PM216MOSFET', + 'vehicle_config_rear_seat_heaters': 1, + 'vehicle_config_rear_seat_type': 0, + 'vehicle_config_rhd': True, + 'vehicle_config_roof_color': 'RoofColorGlass', + 'vehicle_config_seat_type': None, + 'vehicle_config_spoiler_type': 'None', + 'vehicle_config_sun_roof_installed': None, + 'vehicle_config_supports_qr_pairing': False, + 'vehicle_config_third_row_seats': 'None', + 'vehicle_config_timestamp': 1705707520649, + 'vehicle_config_trim_badging': '74d', + 'vehicle_config_use_range_badging': True, + 'vehicle_config_utc_offset': 36000, + 'vehicle_config_webcam_selfie_supported': True, + 'vehicle_config_webcam_supported': True, + 'vehicle_config_wheel_type': 'Pinwheel18CapKit', + 'vehicle_id': '**REDACTED**', + 'vehicle_state_api_version': 71, + 'vehicle_state_autopark_state_v2': 'unavailable', + 'vehicle_state_calendar_supported': True, + 'vehicle_state_car_version': '2023.44.30.8 06f534d46010', + 'vehicle_state_center_display_state': 0, + 'vehicle_state_dashcam_clip_save_available': True, + 'vehicle_state_dashcam_state': 'Recording', + 'vehicle_state_df': 0, + 'vehicle_state_dr': 0, + 'vehicle_state_fd_window': 0, + 'vehicle_state_feature_bitmask': 'fbdffbff,187f', + 'vehicle_state_fp_window': 0, + 'vehicle_state_ft': 0, + 'vehicle_state_is_user_present': False, + 'vehicle_state_locked': False, + 'vehicle_state_media_info_audio_volume': 2.6667, + 'vehicle_state_media_info_audio_volume_increment': 0.333333, + 'vehicle_state_media_info_audio_volume_max': 10.333333, + 'vehicle_state_media_info_media_playback_status': 'Stopped', + 'vehicle_state_media_info_now_playing_album': '', + 'vehicle_state_media_info_now_playing_artist': '', + 'vehicle_state_media_info_now_playing_duration': 0, + 'vehicle_state_media_info_now_playing_elapsed': 0, + 'vehicle_state_media_info_now_playing_source': 'Spotify', + 'vehicle_state_media_info_now_playing_station': '', + 'vehicle_state_media_info_now_playing_title': '', + 'vehicle_state_media_state_remote_control_enabled': True, + 'vehicle_state_notifications_supported': True, + 'vehicle_state_odometer': 6481.019282, + 'vehicle_state_parsed_calendar_supported': True, + 'vehicle_state_pf': 0, + 'vehicle_state_pr': 0, + 'vehicle_state_rd_window': 0, + 'vehicle_state_remote_start': False, + 'vehicle_state_remote_start_enabled': True, + 'vehicle_state_remote_start_supported': True, + 'vehicle_state_rp_window': 0, + 'vehicle_state_rt': 0, + 'vehicle_state_santa_mode': 0, + 'vehicle_state_sentry_mode': False, + 'vehicle_state_sentry_mode_available': True, + 'vehicle_state_service_mode': False, + 'vehicle_state_service_mode_plus': False, + 'vehicle_state_software_update_download_perc': 0, + 'vehicle_state_software_update_expected_duration_sec': 2700, + 'vehicle_state_software_update_install_perc': 1, + 'vehicle_state_software_update_status': '', + 'vehicle_state_software_update_version': ' ', + 'vehicle_state_speed_limit_mode_active': False, + 'vehicle_state_speed_limit_mode_current_limit_mph': 69, + 'vehicle_state_speed_limit_mode_max_limit_mph': 120, + 'vehicle_state_speed_limit_mode_min_limit_mph': 50, + 'vehicle_state_speed_limit_mode_pin_code_set': True, + 'vehicle_state_timestamp': 1705707520649, + 'vehicle_state_tpms_hard_warning_fl': False, + 'vehicle_state_tpms_hard_warning_fr': False, + 'vehicle_state_tpms_hard_warning_rl': False, + 'vehicle_state_tpms_hard_warning_rr': False, + 'vehicle_state_tpms_last_seen_pressure_time_fl': 1705700812, + 'vehicle_state_tpms_last_seen_pressure_time_fr': 1705700793, + 'vehicle_state_tpms_last_seen_pressure_time_rl': 1705700794, + 'vehicle_state_tpms_last_seen_pressure_time_rr': 1705700823, + 'vehicle_state_tpms_pressure_fl': 2.775, + 'vehicle_state_tpms_pressure_fr': 2.8, + 'vehicle_state_tpms_pressure_rl': 2.775, + 'vehicle_state_tpms_pressure_rr': 2.775, + 'vehicle_state_tpms_rcp_front_value': 2.9, + 'vehicle_state_tpms_rcp_rear_value': 2.9, + 'vehicle_state_tpms_soft_warning_fl': False, + 'vehicle_state_tpms_soft_warning_fr': False, + 'vehicle_state_tpms_soft_warning_rl': False, + 'vehicle_state_tpms_soft_warning_rr': False, + 'vehicle_state_valet_mode': False, + 'vehicle_state_valet_pin_needed': False, + 'vehicle_state_vehicle_name': 'Test', + 'vehicle_state_vehicle_self_test_progress': 0, + 'vehicle_state_vehicle_self_test_requested': False, + 'vehicle_state_webcam_available': True, + 'vin': '**REDACTED**', + }), + ]), + }) +# --- diff --git a/tests/components/teslemetry/test_diagnostics.py b/tests/components/teslemetry/test_diagnostics.py new file mode 100644 index 00000000000..fb8eb79a918 --- /dev/null +++ b/tests/components/teslemetry/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Test the Telemetry Diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_platform + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + entry = await setup_platform(hass) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) + assert diag == snapshot From 77d1e2c81257d4b06212e7e011078c21373744eb Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Fri, 12 Apr 2024 11:31:51 -0700 Subject: [PATCH 49/64] Allow customizing display name for energy device (#112834) * Allow customizing display name for energy device * optional typing and comment --- homeassistant/components/energy/data.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index d4533b2fcc8..d0da07da37c 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -136,6 +136,9 @@ class DeviceConsumption(TypedDict): # This is an ever increasing value stat_consumption: str + # An optional custom name for display in energy graphs + name: str | None + class EnergyPreferences(TypedDict): """Dictionary holding the energy data.""" @@ -287,6 +290,7 @@ ENERGY_SOURCE_SCHEMA = vol.All( DEVICE_CONSUMPTION_SCHEMA = vol.Schema( { vol.Required("stat_consumption"): str, + vol.Optional("name"): str, } ) From 6eaf3402c6534d6db5d9d7157c79bf6246f00fe9 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Fri, 12 Apr 2024 20:33:24 +0200 Subject: [PATCH 50/64] Add re-auth-flow to fyta integration (#114972) * add re-auth-flow to fyta integration * add strings for reauth-flow * resolve typing error * update based on review comments * Update homeassistant/components/fyta/config_flow.py Co-authored-by: G Johansson * add async_auth * adjustment based on review commet * Update test_config_flow.py * remove credentials * Update homeassistant/components/fyta/config_flow.py Co-authored-by: G Johansson * Update tests/components/fyta/test_config_flow.py Co-authored-by: G Johansson * Update tests/components/fyta/test_config_flow.py Co-authored-by: G Johansson * Update conftest.py * Update test_config_flow.py * Aktualisieren von conftest.py * Update test_config_flow.py --------- Co-authored-by: G Johansson --- homeassistant/components/fyta/config_flow.py | 71 +++++++++++++++----- homeassistant/components/fyta/coordinator.py | 4 +- homeassistant/components/fyta/strings.json | 11 +++ tests/components/fyta/conftest.py | 7 +- tests/components/fyta/test_config_flow.py | 65 +++++++++++++++++- 5 files changed, 129 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/fyta/config_flow.py b/homeassistant/components/fyta/config_flow.py index 8419352dc44..e11c024ec1f 100644 --- a/homeassistant/components/fyta/config_flow.py +++ b/homeassistant/components/fyta/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -13,7 +14,7 @@ from fyta_cli.fyta_exceptions import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import DOMAIN @@ -30,36 +31,70 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Fyta.""" VERSION = 1 + _entry: ConfigEntry | None = None + + async def async_auth(self, user_input: Mapping[str, Any]) -> dict[str, str]: + """Reusable Auth Helper.""" + fyta = FytaConnector(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + + try: + await fyta.login() + except FytaConnectionError: + return {"base": "cannot_connect"} + except FytaAuthentificationError: + return {"base": "invalid_auth"} + except FytaPasswordError: + return {"base": "invalid_auth", CONF_PASSWORD: "password_error"} + except Exception as e: # pylint: disable=broad-except + _LOGGER.error(e) + return {"base": "unknown"} + finally: + await fyta.client.close() + + return {} async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors = {} if user_input: self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) - fyta = FytaConnector(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) - - try: - await fyta.login() - except FytaConnectionError: - errors["base"] = "cannot_connect" - except FytaAuthentificationError: - errors["base"] = "invalid_auth" - except FytaPasswordError: - errors["base"] = "invalid_auth" - errors[CONF_PASSWORD] = "password_error" - except Exception: # pylint: disable=broad-except - errors["base"] = "unknown" - else: + if not (errors := await self.async_auth(user_input)): return self.async_create_entry( title=user_input[CONF_USERNAME], data=user_input ) - finally: - await fyta.client.close() return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle flow upon an API authentication error.""" + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthorization flow.""" + errors = {} + assert self._entry is not None + + if user_input and not (errors := await self.async_auth(user_input)): + return self.async_update_reload_and_abort( + self._entry, data={**self._entry.data, **user_input} + ) + + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, + {CONF_USERNAME: self._entry.data[CONF_USERNAME], **(user_input or {})}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index c132ee75e72..65bd0cb532c 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -13,7 +13,7 @@ from fyta_cli.fyta_exceptions import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -52,4 +52,4 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, dict[str, Any]]]): except FytaConnectionError as ex: raise ConfigEntryNotReady from ex except (FytaAuthentificationError, FytaPasswordError) as ex: - raise ConfigEntryError from ex + raise ConfigEntryAuthFailed from ex diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 6d4fe68a86c..3df851489bc 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -8,8 +8,19 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "description": "Update your credentials for FYTA API", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", diff --git a/tests/components/fyta/conftest.py b/tests/components/fyta/conftest.py index e35012a02e8..efebf9827b9 100644 --- a/tests/components/fyta/conftest.py +++ b/tests/components/fyta/conftest.py @@ -5,8 +5,6 @@ from unittest.mock import AsyncMock, patch import pytest -from .test_config_flow import ACCESS_TOKEN, EXPIRATION - @pytest.fixture def mock_fyta(): @@ -17,10 +15,7 @@ def mock_fyta(): "homeassistant.components.fyta.config_flow.FytaConnector", return_value=mock_fyta_api, ) as mock_fyta_api: - mock_fyta_api.return_value.login.return_value = { - "access_token": ACCESS_TOKEN, - "expiration": EXPIRATION, - } + mock_fyta_api.return_value.login.return_value = {} yield mock_fyta_api diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py index 60e6fc76c5b..6aad6295819 100644 --- a/tests/components/fyta/test_config_flow.py +++ b/tests/components/fyta/test_config_flow.py @@ -1,6 +1,5 @@ """Test the fyta config flow.""" -from datetime import datetime from unittest.mock import AsyncMock from fyta_cli.fyta_exceptions import ( @@ -20,8 +19,6 @@ from tests.common import MockConfigEntry USERNAME = "fyta_user" PASSWORD = "fyta_pass" -ACCESS_TOKEN = "123xyz" -EXPIRATION = datetime.now() async def test_user_flow( @@ -121,3 +118,65 @@ async def test_duplicate_entry(hass: HomeAssistant, mock_fyta: AsyncMock) -> Non assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (FytaConnectionError, {"base": "cannot_connect"}), + (FytaAuthentificationError, {"base": "invalid_auth"}), + (FytaPasswordError, {"base": "invalid_auth", CONF_PASSWORD: "password_error"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_reauth( + hass: HomeAssistant, + exception: Exception, + error: dict[str, str], + mock_fyta: AsyncMock, + mock_setup_entry, +) -> None: + """Test reauth-flow works.""" + + entry = MockConfigEntry( + domain=DOMAIN, + title=USERNAME, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_fyta.return_value.login.side_effect = exception + + # tests with connection error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == error + + mock_fyta.return_value.login.side_effect = None + + # tests with all information provided + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "other_username", CONF_PASSWORD: "other_password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_USERNAME] == "other_username" + assert entry.data[CONF_PASSWORD] == "other_password" + + assert len(mock_setup_entry.mock_calls) == 1 From f16ee2ded9d905ebeb251c2faf2462c67322f167 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 12 Apr 2024 23:35:59 +0200 Subject: [PATCH 51/64] Update strict connection static page (#115473) --- .pre-commit-config.yaml | 2 +- .../http/strict_connection_static_page.html | 142 +++++++++++++++--- 2 files changed, 119 insertions(+), 25 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 326346a9e81..cd42fecbfa1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: - --ignore-words-list=additionals,alle,alot,astroid,bund,caf,convencional,currenty,datas,farenheit,falsy,fo,frequence,haa,hass,iif,incomfort,ines,ist,nam,nd,pres,pullrequests,resset,rime,ser,serie,te,technik,ue,unsecure,vor,withing,zar - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 - exclude_types: [csv, json] + exclude_types: [csv, json, html] exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 diff --git a/homeassistant/components/http/strict_connection_static_page.html b/homeassistant/components/http/strict_connection_static_page.html index 24049d9a0eb..86ea8e00e90 100644 --- a/homeassistant/components/http/strict_connection_static_page.html +++ b/homeassistant/components/http/strict_connection_static_page.html @@ -1,46 +1,140 @@ - - I'm a Teapot + + Home Assistant - Access denied + -
-

Error 418: I'm a Teapot

-

- Oops! Looks like the server is taking a coffee break.
- Don't worry, it'll be back to brewing your requests in no time! -

-

+
+ + + + +
+
+

You need access

+

+ This device is not known on + Home Assistant. +

+ + + Learn how to get access +
From d74be6d5fed752058a2a1eba3f09fe3422a11e8a Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sat, 13 Apr 2024 00:51:36 +0200 Subject: [PATCH 52/64] Set Ruff RUF001-003 to ignore (#115477) --- homeassistant/components/climate/const.py | 2 +- pyproject.toml | 3 +++ tests/components/flux/test_switch.py | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index c790b8596a9..b1bf78063c7 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -33,7 +33,7 @@ class HVACMode(StrEnum): # Device is in Dry/Humidity mode DRY = "dry" - # Only the fan is on, not fan and another mode like cool + # Only the fan is on, not fan and another mode like cool FAN_ONLY = "fan_only" diff --git a/pyproject.toml b/pyproject.toml index 92b3649d3e5..da975078d01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -750,6 +750,9 @@ ignore = [ "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception "PT012", # `pytest.raises()` block should contain a single simple statement "PT018", # Assertion should be broken down into multiple parts + "RUF001", # String contains ambiguous unicode character. + "RUF002", # Docstring contains ambiguous unicode character. + "RUF003", # Comment contains ambiguous unicode character. "SIM102", # Use a single if statement instead of nested if statements "SIM108", # Use ternary operator {contents} instead of if-else-block "SIM115", # Use context handler for opening files diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index a3eeec10fa5..018d1c43b70 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -1115,7 +1115,7 @@ async def test_flux_with_mired( hass: HomeAssistant, mock_light_entities: list[MockLight], ) -> None: - """Test the flux switch´s mode mired.""" + """Test the flux switch's mode mired.""" setup_test_component_platform(hass, light.DOMAIN, mock_light_entities) assert await async_setup_component( @@ -1176,7 +1176,7 @@ async def test_flux_with_rgb( hass: HomeAssistant, mock_light_entities: list[MockLight], ) -> None: - """Test the flux switch´s mode rgb.""" + """Test the flux switch's mode rgb.""" setup_test_component_platform(hass, light.DOMAIN, mock_light_entities) assert await async_setup_component( From b9899a441c035bf28aec1e30a5167db31adac580 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Apr 2024 14:13:06 -1000 Subject: [PATCH 53/64] Bump zeroconf to 0.132.1 (#115501) --- 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 7c489517dd7..3bddbfea576 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.132.0"] + "requirements": ["zeroconf==0.132.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b253d600a2d..3b4309bcbfa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.4 -zeroconf==0.132.0 +zeroconf==0.132.1 # 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 53d108fdce1..5ab882c2d44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2935,7 +2935,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.132.0 +zeroconf==0.132.1 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca80aa78e00..8be3570bf07 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2273,7 +2273,7 @@ yt-dlp==2024.04.09 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.132.0 +zeroconf==0.132.1 # homeassistant.components.zeversolar zeversolar==0.3.1 From af2c381a0cda8c6046863bebb82151d3580a15b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Apr 2024 19:05:08 -1000 Subject: [PATCH 54/64] Bump zeroconf to 0.132.2 (#115505) --- 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 3bddbfea576..0a76af3b9c2 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.132.1"] + "requirements": ["zeroconf==0.132.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3b4309bcbfa..07885c8a067 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.4 -zeroconf==0.132.1 +zeroconf==0.132.2 # 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 5ab882c2d44..130ff6644c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2935,7 +2935,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.132.1 +zeroconf==0.132.2 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8be3570bf07..d1b55f5c1ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2273,7 +2273,7 @@ yt-dlp==2024.04.09 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.132.1 +zeroconf==0.132.2 # homeassistant.components.zeversolar zeversolar==0.3.1 From 0d0b77c9e4cc9852f7ebbcb101bb4de02d155169 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Apr 2024 19:09:42 -1000 Subject: [PATCH 55/64] Remove eager_start=False from zeroconf (#115498) --- homeassistant/components/zeroconf/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 7b4c06ffb62..bbc89e77a76 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -428,7 +428,6 @@ class ZeroconfDiscovery: zeroconf, async_service_info, service_type, name ), name=f"zeroconf lookup {name}.{service_type}", - eager_start=False, ) async def _async_lookup_and_process_service_update( From 76fefaafb0450740c8338025b9d7e4982a387f9c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 13 Apr 2024 08:18:45 +0200 Subject: [PATCH 56/64] Move out demo notify tests to the notify platform (#115504) * Move test file * Make independent of demo platform * Restore tests for demo platform for coverage --- tests/components/notify/test_legacy.py | 206 +++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 tests/components/notify/test_legacy.py diff --git a/tests/components/notify/test_legacy.py b/tests/components/notify/test_legacy.py new file mode 100644 index 00000000000..6653e70275d --- /dev/null +++ b/tests/components/notify/test_legacy.py @@ -0,0 +1,206 @@ +"""The tests for legacy notify services.""" + +from collections.abc import Mapping +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest +import voluptuous as vol + +from homeassistant.components import notify +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.setup import async_setup_component + +from tests.common import MockPlatform, mock_platform + + +class MockNotifyPlatform(MockPlatform): + """Help to set up a legacy test notify service.""" + + def __init__(self, async_get_service: Any = None, get_service: Any = None) -> None: + """Return a legacy notify service.""" + super().__init__() + if get_service: + self.get_service = get_service + if async_get_service: + self.async_get_service = async_get_service + + +def mock_notify_platform( + hass: HomeAssistant, + tmp_path: Path, + integration: str = "notify", + async_get_service: Any = None, + get_service: Any = None, +): + """Specialize the mock platform for legacy notify service.""" + loaded_platform = MockNotifyPlatform(async_get_service, get_service) + mock_platform(hass, f"{integration}.notify", loaded_platform) + + return loaded_platform + + +async def help_setup_notify( + hass: HomeAssistant, tmp_path: Path, targets: dict[str, None] | None = None +) -> MagicMock: + """Help set up a platform notify service.""" + send_message_mock = MagicMock() + + class _TestNotifyService(notify.BaseNotificationService): + def __init__(self, targets: dict[str, None] | None) -> None: + """Initialize service.""" + self._targets = targets + super().__init__() + + @property + def targets(self) -> Mapping[str, Any] | None: + """Return a dictionary of registered targets.""" + return self._targets + + def send_message(self, message: str, **kwargs: Any) -> None: + """Send a message.""" + send_message_mock(message, kwargs) + + async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> notify.BaseNotificationService: + """Get notify service for mocked platform.""" + return _TestNotifyService(targets) + + # Mock platform with service + mock_notify_platform(hass, tmp_path, "test", async_get_service=async_get_service) + # Setup the platform + await async_setup_component(hass, "notify", {"notify": [{"platform": "test"}]}) + await hass.async_block_till_done() + + # Return mock for assertion service calls + return send_message_mock + + +async def test_sending_none_message(hass: HomeAssistant, tmp_path: Path) -> None: + """Test send with None as message.""" + send_message_mock = await help_setup_notify(hass, tmp_path) + with pytest.raises(vol.Invalid) as exc: + await hass.services.async_call( + notify.DOMAIN, notify.SERVICE_NOTIFY, {notify.ATTR_MESSAGE: None} + ) + await hass.async_block_till_done() + assert ( + str(exc.value) + == "template value is None for dictionary value @ data['message']" + ) + send_message_mock.assert_not_called() + + +async def test_sending_templated_message(hass: HomeAssistant, tmp_path: Path) -> None: + """Send a templated message.""" + send_message_mock = await help_setup_notify(hass, tmp_path) + hass.states.async_set("sensor.temperature", 10) + data = { + notify.ATTR_MESSAGE: "{{states.sensor.temperature.state}}", + notify.ATTR_TITLE: "{{ states.sensor.temperature.name }}", + } + await hass.services.async_call(notify.DOMAIN, notify.SERVICE_NOTIFY, data) + await hass.async_block_till_done() + send_message_mock.assert_called_once_with( + "10", {"title": "temperature", "data": None} + ) + + +async def test_method_forwards_correct_data( + hass: HomeAssistant, tmp_path: Path +) -> None: + """Test that all data from the service gets forwarded to service.""" + send_message_mock = await help_setup_notify(hass, tmp_path) + data = { + notify.ATTR_MESSAGE: "my message", + notify.ATTR_TITLE: "my title", + notify.ATTR_DATA: {"hello": "world"}, + } + await hass.services.async_call(notify.DOMAIN, notify.SERVICE_NOTIFY, data) + await hass.async_block_till_done() + send_message_mock.assert_called_once_with( + "my message", {"title": "my title", "data": {"hello": "world"}} + ) + + +async def test_calling_notify_from_script_loaded_from_yaml_without_title( + hass: HomeAssistant, tmp_path: Path +) -> None: + """Test if we can call a notify from a script.""" + send_message_mock = await help_setup_notify(hass, tmp_path) + step = { + "service": "notify.notify", + "data": { + "data": {"push": {"sound": "US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav"}} + }, + "data_template": {"message": "Test 123 {{ 2 + 2 }}\n"}, + } + await async_setup_component( + hass, "script", {"script": {"test": {"sequence": step}}} + ) + await hass.services.async_call("script", "test") + await hass.async_block_till_done() + send_message_mock.assert_called_once_with( + "Test 123 4", + {"data": {"push": {"sound": "US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav"}}}, + ) + + +async def test_calling_notify_from_script_loaded_from_yaml_with_title( + hass: HomeAssistant, tmp_path: Path +) -> None: + """Test if we can call a notify from a script.""" + send_message_mock = await help_setup_notify(hass, tmp_path) + step = { + "service": "notify.notify", + "data": { + "data": {"push": {"sound": "US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav"}} + }, + "data_template": {"message": "Test 123 {{ 2 + 2 }}\n", "title": "Test"}, + } + await async_setup_component( + hass, "script", {"script": {"test": {"sequence": step}}} + ) + await hass.services.async_call("script", "test") + await hass.async_block_till_done() + send_message_mock.assert_called_once_with( + "Test 123 4", + { + "title": "Test", + "data": { + "push": {"sound": "US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav"} + }, + }, + ) + + +async def test_targets_are_services(hass: HomeAssistant, tmp_path: Path) -> None: + """Test that all targets are exposed as individual services.""" + await help_setup_notify(hass, tmp_path, targets={"a": 1, "b": 2}) + assert hass.services.has_service("notify", "notify") is not None + assert hass.services.has_service("notify", "test_a") is not None + assert hass.services.has_service("notify", "test_b") is not None + + +async def test_messages_to_targets_route(hass: HomeAssistant, tmp_path: Path) -> None: + """Test message routing to specific target services.""" + send_message_mock = await help_setup_notify( + hass, tmp_path, targets={"target_name": "test target id"} + ) + + await hass.services.async_call( + "notify", + "test_target_name", + {"message": "my message", "title": "my title", "data": {"hello": "world"}}, + ) + await hass.async_block_till_done() + + send_message_mock.assert_called_once_with( + "my message", + {"target": ["test target id"], "title": "my title", "data": {"hello": "world"}}, + ) From bb9330135dd870503a8a84f1376fe15546960907 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Apr 2024 21:13:01 -1000 Subject: [PATCH 57/64] Fix race in influxdb test (#115514) The patch was still too late in #115442 There is no good candidate to patch here since the late operation is the error log that is being tested. Patching the logger did not seem like a good idea so I went with patching to wait for the error to be emitted since emit is the public API of the log handler and was less likely to change --- tests/components/influxdb/test_init.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index ad3fddeaf6e..9d672b7ceb0 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -1,9 +1,9 @@ """The tests for the InfluxDB component.""" -import asyncio from dataclasses import dataclass import datetime from http import HTTPStatus +import logging from unittest.mock import ANY, MagicMock, Mock, call, patch import pytest @@ -1573,21 +1573,25 @@ async def test_invalid_inputs_error( await _setup(hass, mock_client, config_ext, get_write_api) write_api = get_write_api(mock_client) + write_api.side_effect = test_exception - write_api_done_event = asyncio.Event() + log_emit_done = hass.loop.create_future() - def wait_for_write(*args, **kwargs): - hass.loop.call_soon_threadsafe(write_api_done_event.set) - raise test_exception + original_emit = caplog.handler.emit - write_api.side_effect = wait_for_write + def wait_for_emit(record: logging.LogRecord) -> None: + original_emit(record) + if record.levelname == "ERROR": + hass.loop.call_soon_threadsafe(log_emit_done.set_result, None) - with patch(f"{INFLUX_PATH}.time.sleep") as sleep: - write_api_done_event.clear() + with ( + patch(f"{INFLUX_PATH}.time.sleep") as sleep, + patch.object(caplog.handler, "emit", wait_for_emit), + ): hass.states.async_set("fake.something", 1) await hass.async_block_till_done() await async_wait_for_queue_to_process(hass) - await write_api_done_event.wait() + await log_emit_done await hass.async_block_till_done() write_api.assert_called_once() From 1a9ff8c8fae6fe914f2eb2e7d77e7d9d75a1e200 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sat, 13 Apr 2024 09:46:04 +0200 Subject: [PATCH 58/64] Ignore Ruff RUF015 (#115481) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index da975078d01..6b61766d4b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -753,6 +753,7 @@ ignore = [ "RUF001", # String contains ambiguous unicode character. "RUF002", # Docstring contains ambiguous unicode character. "RUF003", # Comment contains ambiguous unicode character. + "RUF015", # Prefer next(...) over single element slice "SIM102", # Use a single if statement instead of nested if statements "SIM108", # Use ternary operator {contents} instead of if-else-block "SIM115", # Use context handler for opening files From 223fefbbfacc813cd1a9ffde9499b96302c76da7 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sat, 13 Apr 2024 09:56:33 +0200 Subject: [PATCH 59/64] Enable Ruff RUF018 (#115485) --- homeassistant/components/api/__init__.py | 3 ++- homeassistant/components/light/__init__.py | 12 ++++++------ .../components/mqtt_statestream/__init__.py | 3 ++- homeassistant/components/ring/camera.py | 3 ++- homeassistant/components/zwave_js/diagnostics.py | 3 ++- pyproject.toml | 1 + tests/ruff.toml | 1 + 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 496b6fa5fb1..73751daa6cb 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -284,7 +284,8 @@ class APIEntityStateView(HomeAssistantView): # Read the state back for our response status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK - assert (state := hass.states.get(entity_id)) + state = hass.states.get(entity_id) + assert state resp = self.json(state.as_dict(), status_code) resp.headers.add("Location", f"/api/states/{entity_id}") diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 332d701148e..b3b1330b3a1 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -517,13 +517,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_COLOR_TEMP_KELVIN] ) elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: - assert (rgb_color := params.pop(ATTR_RGB_COLOR)) is not None + rgb_color = params.pop(ATTR_RGB_COLOR) + assert rgb_color is not None if ColorMode.RGBW in supported_color_modes: params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) elif ColorMode.RGBWW in supported_color_modes: - # https://github.com/python/mypy/issues/13673 params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, # type: ignore[call-arg] + *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin, ) @@ -584,9 +584,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif ( ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes ): - assert (rgbww_color := params.pop(ATTR_RGBWW_COLOR)) is not None - # https://github.com/python/mypy/issues/13673 - rgb_color = color_util.color_rgbww_to_rgb( # type: ignore[call-arg] + rgbww_color = params.pop(ATTR_RGBWW_COLOR) + assert rgbww_color is not None + rgb_color = color_util.color_rgbww_to_rgb( *rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) if ColorMode.RGB in supported_color_modes: diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index 6a1a791d7ac..3a0953a0158 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -57,7 +57,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _state_publisher(evt: Event[EventStateChangedData]) -> None: entity_id = evt.data["entity_id"] - assert (new_state := evt.data["new_state"]) + new_state = evt.data["new_state"] + assert new_state payload = new_state.state diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 282f9816c4c..a5144777eaa 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -167,7 +167,8 @@ class RingCam(RingEntity[RingDoorBell], Camera): def _get_video(self) -> str | None: if self._last_event is None: return None - assert (event_id := self._last_event.get("id")) and isinstance(event_id, int) + event_id = self._last_event.get("id") + assert event_id and isinstance(event_id, int) return self._device.recording_url(event_id) @exception_wrap diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 777d45efddb..3d61699472d 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -151,7 +151,8 @@ async def async_get_device_diagnostics( client: Client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] identifiers = get_home_and_node_id_from_device_entry(device) node_id = identifiers[1] if identifiers else None - assert (driver := client.driver) + driver = client.driver + assert driver if node_id is None or node_id not in driver.controller.nodes: raise ValueError(f"Node for device {device.id} can't be found") node = driver.controller.nodes[node_id] diff --git a/pyproject.toml b/pyproject.toml index 6b61766d4b8..b9111f505c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -701,6 +701,7 @@ select = [ "RUF005", # Consider iterable unpacking instead of concatenation "RUF006", # Store a reference to the return value of asyncio.create_task "RUF013", # PEP 484 prohibits implicit Optional + "RUF018", # Avoid assignment expressions in assert statements # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up "S102", # Use of exec detected "S103", # bad-file-permissions diff --git a/tests/ruff.toml b/tests/ruff.toml index 5455e211762..87725160751 100644 --- a/tests/ruff.toml +++ b/tests/ruff.toml @@ -6,6 +6,7 @@ extend = "../pyproject.toml" extend-ignore = [ "B904", # Use raise from to specify exception cause "N815", # Variable {name} in class scope should not be mixedCase + "RUF018", # Avoid assignment expressions in assert statements ] [lint.isort] From 127c27c9a7b11b1048b0e1a153a8ee3eb26733fb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 13 Apr 2024 10:14:58 +0200 Subject: [PATCH 60/64] Isolate legacy notify tests (#115470) * Isolate legacy notify tests * Rebase * Refactor --- tests/components/notify/test_init.py | 460 +------------------------ tests/components/notify/test_legacy.py | 423 ++++++++++++++++++++++- 2 files changed, 424 insertions(+), 459 deletions(-) diff --git a/tests/components/notify/test_init.py b/tests/components/notify/test_init.py index 1f9ec81e36a..1ecfc0d9ecf 100644 --- a/tests/components/notify/test_init.py +++ b/tests/components/notify/test_init.py @@ -1,16 +1,11 @@ -"""The tests for notify services that change targets.""" +"""The tests for notify entity platform.""" -import asyncio import copy -from pathlib import Path -from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock import pytest import voluptuous as vol -import yaml -from homeassistant import config as hass_config from homeassistant.components import notify from homeassistant.components.notify import ( DOMAIN, @@ -19,23 +14,13 @@ from homeassistant.components.notify import ( NotifyEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - SERVICE_RELOAD, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - Platform, -) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.reload import async_setup_reload_service -from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, MockEntity, MockModule, - MockPlatform, - async_get_persistent_notifications, mock_integration, mock_platform, mock_restore_cache, @@ -226,442 +211,3 @@ async def test_name(hass: HomeAssistant, config_flow_fixture: None) -> None: state = hass.states.get(entity3.entity_id) assert state assert state.attributes == {} - - -class MockNotifyPlatform(MockPlatform): - """Help to set up a legacy test notify service.""" - - def __init__(self, async_get_service: Any = None, get_service: Any = None) -> None: - """Return a legacy notify service.""" - super().__init__() - if get_service: - self.get_service = get_service - if async_get_service: - self.async_get_service = async_get_service - - -def mock_notify_platform( - hass: HomeAssistant, - tmp_path: Path, - integration: str = "notify", - async_get_service: Any = None, - get_service: Any = None, -): - """Specialize the mock platform for legacy notify service.""" - loaded_platform = MockNotifyPlatform(async_get_service, get_service) - mock_platform(hass, f"{integration}.notify", loaded_platform) - - return loaded_platform - - -async def test_same_targets(hass: HomeAssistant) -> None: - """Test not changing the targets in a legacy notify service.""" - test = NotificationService(hass) - await test.async_setup(hass, "notify", "test") - await test.async_register_services() - await hass.async_block_till_done() - - assert hasattr(test, "registered_targets") - assert test.registered_targets == {"test_a": 1, "test_b": 2} - - await test.async_register_services() - await hass.async_block_till_done() - assert test.registered_targets == {"test_a": 1, "test_b": 2} - - -async def test_change_targets(hass: HomeAssistant) -> None: - """Test changing the targets in a legacy notify service.""" - test = NotificationService(hass) - await test.async_setup(hass, "notify", "test") - await test.async_register_services() - await hass.async_block_till_done() - - assert hasattr(test, "registered_targets") - assert test.registered_targets == {"test_a": 1, "test_b": 2} - - test.target_list = {"a": 0} - await test.async_register_services() - await hass.async_block_till_done() - assert test.target_list == {"a": 0} - assert test.registered_targets == {"test_a": 0} - - -async def test_add_targets(hass: HomeAssistant) -> None: - """Test adding the targets in a legacy notify service.""" - test = NotificationService(hass) - await test.async_setup(hass, "notify", "test") - await test.async_register_services() - await hass.async_block_till_done() - - assert hasattr(test, "registered_targets") - assert test.registered_targets == {"test_a": 1, "test_b": 2} - - test.target_list = {"a": 1, "b": 2, "c": 3} - await test.async_register_services() - await hass.async_block_till_done() - assert test.target_list == {"a": 1, "b": 2, "c": 3} - assert test.registered_targets == {"test_a": 1, "test_b": 2, "test_c": 3} - - -async def test_remove_targets(hass: HomeAssistant) -> None: - """Test removing targets from the targets in a legacy notify service.""" - test = NotificationService(hass) - await test.async_setup(hass, "notify", "test") - await test.async_register_services() - await hass.async_block_till_done() - - assert hasattr(test, "registered_targets") - assert test.registered_targets == {"test_a": 1, "test_b": 2} - - test.target_list = {"c": 1} - await test.async_register_services() - await hass.async_block_till_done() - assert test.target_list == {"c": 1} - assert test.registered_targets == {"test_c": 1} - - -class NotificationService(notify.BaseNotificationService): - """A test class for legacy notification services.""" - - def __init__( - self, - hass: HomeAssistant, - target_list: dict[str, Any] | None = None, - name="notify", - ) -> None: - """Initialize the service.""" - - async def _async_make_reloadable(hass: HomeAssistant) -> None: - """Initialize the reload service.""" - await async_setup_reload_service(hass, name, [notify.DOMAIN]) - - self.hass = hass - self.target_list = target_list or {"a": 1, "b": 2} - hass.async_create_task(_async_make_reloadable(hass)) - - @property - def targets(self): - """Return a dictionary of devices.""" - return self.target_list - - -async def test_warn_template( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test warning when template used.""" - assert await async_setup_component(hass, "notify", {}) - - await hass.services.async_call( - "notify", - "persistent_notification", - {"message": "{{ 1 + 1 }}", "title": "Test notif {{ 1 + 1 }}"}, - blocking=True, - ) - # We should only log it once - assert caplog.text.count("Passing templates to notify service is deprecated") == 1 - notifications = async_get_persistent_notifications(hass) - assert len(notifications) == 1 - - -async def test_invalid_platform( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path -) -> None: - """Test service setup with an invalid platform.""" - mock_notify_platform(hass, tmp_path, "testnotify1") - # Setup the platform - await async_setup_component( - hass, "notify", {"notify": [{"platform": "testnotify1"}]} - ) - await hass.async_block_till_done() - assert "Invalid notify platform" in caplog.text - caplog.clear() - # Setup the second testnotify2 platform dynamically - mock_notify_platform(hass, tmp_path, "testnotify2") - await async_load_platform( - hass, - "notify", - "testnotify2", - {}, - hass_config={"notify": [{"platform": "testnotify2"}]}, - ) - await hass.async_block_till_done() - assert "Invalid notify platform" in caplog.text - - -async def test_invalid_service( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path -) -> None: - """Test service setup with an invalid service object or platform.""" - - def get_service(hass, config, discovery_info=None): - """Return None for an invalid notify service.""" - return None - - mock_notify_platform(hass, tmp_path, "testnotify", get_service=get_service) - # Setup the second testnotify2 platform dynamically - await async_load_platform( - hass, - "notify", - "testnotify", - {}, - hass_config={"notify": [{"platform": "testnotify"}]}, - ) - await hass.async_block_till_done() - assert "Failed to initialize notification service testnotify" in caplog.text - caplog.clear() - - await async_load_platform( - hass, - "notify", - "testnotifyinvalid", - {"notify": [{"platform": "testnotifyinvalid"}]}, - hass_config={"notify": [{"platform": "testnotifyinvalid"}]}, - ) - await hass.async_block_till_done() - assert "Unknown notification service specified" in caplog.text - - -async def test_platform_setup_with_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path -) -> None: - """Test service setup with an invalid setup.""" - - async def async_get_service(hass, config, discovery_info=None): - """Return None for an invalid notify service.""" - raise Exception("Setup error") - - mock_notify_platform( - hass, tmp_path, "testnotify", async_get_service=async_get_service - ) - # Setup the second testnotify2 platform dynamically - await async_load_platform( - hass, - "notify", - "testnotify", - {}, - hass_config={"notify": [{"platform": "testnotify"}]}, - ) - await hass.async_block_till_done() - assert "Error setting up platform testnotify" in caplog.text - - -async def test_reload_with_notify_builtin_platform_reload( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path -) -> None: - """Test reload using the legacy notify platform reload method.""" - - async def async_get_service(hass, config, discovery_info=None): - """Get notify service for mocked platform.""" - targetlist = {"a": 1, "b": 2} - return NotificationService(hass, targetlist, "testnotify") - - # platform with service - mock_notify_platform( - hass, tmp_path, "testnotify", async_get_service=async_get_service - ) - - # Perform a reload using the notify module for testnotify (without services) - await notify.async_reload(hass, "testnotify") - - # Setup the platform - await async_setup_component( - hass, "notify", {"notify": [{"platform": "testnotify"}]} - ) - await hass.async_block_till_done() - assert hass.services.has_service(notify.DOMAIN, "testnotify_a") - assert hass.services.has_service(notify.DOMAIN, "testnotify_b") - - # Perform a reload using the notify module for testnotify (with services) - await notify.async_reload(hass, "testnotify") - assert hass.services.has_service(notify.DOMAIN, "testnotify_a") - assert hass.services.has_service(notify.DOMAIN, "testnotify_b") - - -async def test_setup_platform_and_reload( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path -) -> None: - """Test service setup and reload.""" - get_service_called = Mock() - - async def async_get_service(hass, config, discovery_info=None): - """Get notify service for mocked platform.""" - get_service_called(config, discovery_info) - targetlist = {"a": 1, "b": 2} - return NotificationService(hass, targetlist, "testnotify") - - async def async_get_service2(hass, config, discovery_info=None): - """Get legacy notify service for mocked platform.""" - get_service_called(config, discovery_info) - targetlist = {"c": 3, "d": 4} - return NotificationService(hass, targetlist, "testnotify2") - - # Mock first platform - mock_notify_platform( - hass, tmp_path, "testnotify", async_get_service=async_get_service - ) - - # Initialize a second platform testnotify2 - mock_notify_platform( - hass, tmp_path, "testnotify2", async_get_service=async_get_service2 - ) - - # Setup the testnotify platform - await async_setup_component( - hass, "notify", {"notify": [{"platform": "testnotify"}]} - ) - await hass.async_block_till_done() - assert hass.services.has_service("testnotify", SERVICE_RELOAD) - assert hass.services.has_service(notify.DOMAIN, "testnotify_a") - assert hass.services.has_service(notify.DOMAIN, "testnotify_b") - assert get_service_called.call_count == 1 - assert get_service_called.call_args[0][0] == {"platform": "testnotify"} - assert get_service_called.call_args[0][1] is None - get_service_called.reset_mock() - - # Setup the second testnotify2 platform dynamically - await async_load_platform( - hass, - "notify", - "testnotify2", - {}, - hass_config={"notify": [{"platform": "testnotify"}]}, - ) - await hass.async_block_till_done() - assert hass.services.has_service("testnotify2", SERVICE_RELOAD) - assert hass.services.has_service(notify.DOMAIN, "testnotify2_c") - assert hass.services.has_service(notify.DOMAIN, "testnotify2_d") - assert get_service_called.call_count == 1 - assert get_service_called.call_args[0][0] == {} - assert get_service_called.call_args[0][1] == {} - get_service_called.reset_mock() - - # Perform a reload - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump({"notify": [{"platform": "testnotify"}]}) - new_yaml_config_file.write_text(new_yaml_config) - - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - await hass.services.async_call( - "testnotify", - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.services.async_call( - "testnotify2", - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - - # Check if the notify services from setup still exist - assert hass.services.has_service(notify.DOMAIN, "testnotify_a") - assert hass.services.has_service(notify.DOMAIN, "testnotify_b") - assert get_service_called.call_count == 1 - assert get_service_called.call_args[0][0] == {"platform": "testnotify"} - assert get_service_called.call_args[0][1] is None - - # Check if the dynamically notify services from setup were removed - assert not hass.services.has_service(notify.DOMAIN, "testnotify2_c") - assert not hass.services.has_service(notify.DOMAIN, "testnotify2_d") - - -async def test_setup_platform_before_notify_setup( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path -) -> None: - """Test trying to setup a platform before legacy notify service is setup.""" - get_service_called = Mock() - - async def async_get_service(hass, config, discovery_info=None): - """Get notify service for mocked platform.""" - get_service_called(config, discovery_info) - targetlist = {"a": 1, "b": 2} - return NotificationService(hass, targetlist, "testnotify") - - async def async_get_service2(hass, config, discovery_info=None): - """Get notify service for mocked platform.""" - get_service_called(config, discovery_info) - targetlist = {"c": 3, "d": 4} - return NotificationService(hass, targetlist, "testnotify2") - - # Mock first platform - mock_notify_platform( - hass, tmp_path, "testnotify", async_get_service=async_get_service - ) - - # Initialize a second platform testnotify2 - mock_notify_platform( - hass, tmp_path, "testnotify2", async_get_service=async_get_service2 - ) - - hass_config = {"notify": [{"platform": "testnotify"}]} - - # Setup the second testnotify2 platform from discovery - load_coro = async_load_platform( - hass, Platform.NOTIFY, "testnotify2", {}, hass_config=hass_config - ) - - # Setup the testnotify platform - setup_coro = async_setup_component(hass, "notify", hass_config) - - load_task = asyncio.create_task(load_coro) - setup_task = asyncio.create_task(setup_coro) - - await asyncio.gather(load_task, setup_task) - - await hass.async_block_till_done() - assert hass.services.has_service(notify.DOMAIN, "testnotify_a") - assert hass.services.has_service(notify.DOMAIN, "testnotify_b") - assert hass.services.has_service(notify.DOMAIN, "testnotify2_c") - assert hass.services.has_service(notify.DOMAIN, "testnotify2_d") - - -async def test_setup_platform_after_notify_setup( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path -) -> None: - """Test trying to setup a platform after legacy notify service is set up.""" - get_service_called = Mock() - - async def async_get_service(hass, config, discovery_info=None): - """Get notify service for mocked platform.""" - get_service_called(config, discovery_info) - targetlist = {"a": 1, "b": 2} - return NotificationService(hass, targetlist, "testnotify") - - async def async_get_service2(hass, config, discovery_info=None): - """Get notify service for mocked platform.""" - get_service_called(config, discovery_info) - targetlist = {"c": 3, "d": 4} - return NotificationService(hass, targetlist, "testnotify2") - - # Mock first platform - mock_notify_platform( - hass, tmp_path, "testnotify", async_get_service=async_get_service - ) - - # Initialize a second platform testnotify2 - mock_notify_platform( - hass, tmp_path, "testnotify2", async_get_service=async_get_service2 - ) - - hass_config = {"notify": [{"platform": "testnotify"}]} - - # Setup the second testnotify2 platform from discovery - load_coro = async_load_platform( - hass, Platform.NOTIFY, "testnotify2", {}, hass_config=hass_config - ) - - # Setup the testnotify platform - setup_coro = async_setup_component(hass, "notify", hass_config) - - setup_task = asyncio.create_task(setup_coro) - load_task = asyncio.create_task(load_coro) - - await asyncio.gather(load_task, setup_task) - - await hass.async_block_till_done() - assert hass.services.has_service(notify.DOMAIN, "testnotify_a") - assert hass.services.has_service(notify.DOMAIN, "testnotify_b") - assert hass.services.has_service(notify.DOMAIN, "testnotify2_c") - assert hass.services.has_service(notify.DOMAIN, "testnotify2_d") diff --git a/tests/components/notify/test_legacy.py b/tests/components/notify/test_legacy.py index 6653e70275d..71424beeda9 100644 --- a/tests/components/notify/test_legacy.py +++ b/tests/components/notify/test_legacy.py @@ -1,19 +1,50 @@ """The tests for legacy notify services.""" +import asyncio from collections.abc import Mapping from pathlib import Path from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock, patch import pytest import voluptuous as vol +import yaml +from homeassistant import config as hass_config from homeassistant.components import notify +from homeassistant.const import SERVICE_RELOAD, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component -from tests.common import MockPlatform, mock_platform +from tests.common import MockPlatform, async_get_persistent_notifications, mock_platform + + +class NotificationService(notify.BaseNotificationService): + """A test class for legacy notification services.""" + + def __init__( + self, + hass: HomeAssistant, + target_list: dict[str, Any] | None = None, + name="notify", + ) -> None: + """Initialize the service.""" + + async def _async_make_reloadable(hass: HomeAssistant) -> None: + """Initialize the reload service.""" + await async_setup_reload_service(hass, name, [notify.DOMAIN]) + + self.hass = hass + self.target_list = target_list or {"a": 1, "b": 2} + hass.async_create_task(_async_make_reloadable(hass)) + + @property + def targets(self): + """Return a dictionary of devices.""" + return self.target_list class MockNotifyPlatform(MockPlatform): @@ -81,6 +112,394 @@ async def help_setup_notify( return send_message_mock +async def test_same_targets(hass: HomeAssistant) -> None: + """Test not changing the targets in a legacy notify service.""" + test = NotificationService(hass) + await test.async_setup(hass, "notify", "test") + await test.async_register_services() + await hass.async_block_till_done() + + assert hasattr(test, "registered_targets") + assert test.registered_targets == {"test_a": 1, "test_b": 2} + + await test.async_register_services() + await hass.async_block_till_done() + assert test.registered_targets == {"test_a": 1, "test_b": 2} + + +async def test_change_targets(hass: HomeAssistant) -> None: + """Test changing the targets in a legacy notify service.""" + test = NotificationService(hass) + await test.async_setup(hass, "notify", "test") + await test.async_register_services() + await hass.async_block_till_done() + + assert hasattr(test, "registered_targets") + assert test.registered_targets == {"test_a": 1, "test_b": 2} + + test.target_list = {"a": 0} + await test.async_register_services() + await hass.async_block_till_done() + assert test.target_list == {"a": 0} + assert test.registered_targets == {"test_a": 0} + + +async def test_add_targets(hass: HomeAssistant) -> None: + """Test adding the targets in a legacy notify service.""" + test = NotificationService(hass) + await test.async_setup(hass, "notify", "test") + await test.async_register_services() + await hass.async_block_till_done() + + assert hasattr(test, "registered_targets") + assert test.registered_targets == {"test_a": 1, "test_b": 2} + + test.target_list = {"a": 1, "b": 2, "c": 3} + await test.async_register_services() + await hass.async_block_till_done() + assert test.target_list == {"a": 1, "b": 2, "c": 3} + assert test.registered_targets == {"test_a": 1, "test_b": 2, "test_c": 3} + + +async def test_remove_targets(hass: HomeAssistant) -> None: + """Test removing targets from the targets in a legacy notify service.""" + test = NotificationService(hass) + await test.async_setup(hass, "notify", "test") + await test.async_register_services() + await hass.async_block_till_done() + + assert hasattr(test, "registered_targets") + assert test.registered_targets == {"test_a": 1, "test_b": 2} + + test.target_list = {"c": 1} + await test.async_register_services() + await hass.async_block_till_done() + assert test.target_list == {"c": 1} + assert test.registered_targets == {"test_c": 1} + + +async def test_warn_template( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test warning when template used.""" + assert await async_setup_component(hass, "notify", {}) + + await hass.services.async_call( + "notify", + "persistent_notification", + {"message": "{{ 1 + 1 }}", "title": "Test notif {{ 1 + 1 }}"}, + blocking=True, + ) + # We should only log it once + assert caplog.text.count("Passing templates to notify service is deprecated") == 1 + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + + +async def test_invalid_platform( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """Test service setup with an invalid platform.""" + mock_notify_platform(hass, tmp_path, "testnotify1") + # Setup the platform + await async_setup_component( + hass, "notify", {"notify": [{"platform": "testnotify1"}]} + ) + await hass.async_block_till_done() + assert "Invalid notify platform" in caplog.text + caplog.clear() + # Setup the second testnotify2 platform dynamically + mock_notify_platform(hass, tmp_path, "testnotify2") + await async_load_platform( + hass, + "notify", + "testnotify2", + {}, + hass_config={"notify": [{"platform": "testnotify2"}]}, + ) + await hass.async_block_till_done() + assert "Invalid notify platform" in caplog.text + + +async def test_invalid_service( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """Test service setup with an invalid service object or platform.""" + + def get_service(hass, config, discovery_info=None): + """Return None for an invalid notify service.""" + return None + + mock_notify_platform(hass, tmp_path, "testnotify", get_service=get_service) + # Setup the second testnotify2 platform dynamically + await async_load_platform( + hass, + "notify", + "testnotify", + {}, + hass_config={"notify": [{"platform": "testnotify"}]}, + ) + await hass.async_block_till_done() + assert "Failed to initialize notification service testnotify" in caplog.text + caplog.clear() + + await async_load_platform( + hass, + "notify", + "testnotifyinvalid", + {"notify": [{"platform": "testnotifyinvalid"}]}, + hass_config={"notify": [{"platform": "testnotifyinvalid"}]}, + ) + await hass.async_block_till_done() + assert "Unknown notification service specified" in caplog.text + + +async def test_platform_setup_with_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """Test service setup with an invalid setup.""" + + async def async_get_service(hass, config, discovery_info=None): + """Return None for an invalid notify service.""" + raise Exception("Setup error") + + mock_notify_platform( + hass, tmp_path, "testnotify", async_get_service=async_get_service + ) + # Setup the second testnotify2 platform dynamically + await async_load_platform( + hass, + "notify", + "testnotify", + {}, + hass_config={"notify": [{"platform": "testnotify"}]}, + ) + await hass.async_block_till_done() + assert "Error setting up platform testnotify" in caplog.text + + +async def test_reload_with_notify_builtin_platform_reload( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """Test reload using the legacy notify platform reload method.""" + + async def async_get_service(hass, config, discovery_info=None): + """Get notify service for mocked platform.""" + targetlist = {"a": 1, "b": 2} + return NotificationService(hass, targetlist, "testnotify") + + # platform with service + mock_notify_platform( + hass, tmp_path, "testnotify", async_get_service=async_get_service + ) + + # Perform a reload using the notify module for testnotify (without services) + await notify.async_reload(hass, "testnotify") + + # Setup the platform + await async_setup_component( + hass, "notify", {"notify": [{"platform": "testnotify"}]} + ) + await hass.async_block_till_done() + assert hass.services.has_service(notify.DOMAIN, "testnotify_a") + assert hass.services.has_service(notify.DOMAIN, "testnotify_b") + + # Perform a reload using the notify module for testnotify (with services) + await notify.async_reload(hass, "testnotify") + assert hass.services.has_service(notify.DOMAIN, "testnotify_a") + assert hass.services.has_service(notify.DOMAIN, "testnotify_b") + + +async def test_setup_platform_and_reload( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """Test service setup and reload.""" + get_service_called = Mock() + + async def async_get_service(hass, config, discovery_info=None): + """Get notify service for mocked platform.""" + get_service_called(config, discovery_info) + targetlist = {"a": 1, "b": 2} + return NotificationService(hass, targetlist, "testnotify") + + async def async_get_service2(hass, config, discovery_info=None): + """Get legacy notify service for mocked platform.""" + get_service_called(config, discovery_info) + targetlist = {"c": 3, "d": 4} + return NotificationService(hass, targetlist, "testnotify2") + + # Mock first platform + mock_notify_platform( + hass, tmp_path, "testnotify", async_get_service=async_get_service + ) + + # Initialize a second platform testnotify2 + mock_notify_platform( + hass, tmp_path, "testnotify2", async_get_service=async_get_service2 + ) + + # Setup the testnotify platform + await async_setup_component( + hass, "notify", {"notify": [{"platform": "testnotify"}]} + ) + await hass.async_block_till_done() + assert hass.services.has_service("testnotify", SERVICE_RELOAD) + assert hass.services.has_service(notify.DOMAIN, "testnotify_a") + assert hass.services.has_service(notify.DOMAIN, "testnotify_b") + assert get_service_called.call_count == 1 + assert get_service_called.call_args[0][0] == {"platform": "testnotify"} + assert get_service_called.call_args[0][1] is None + get_service_called.reset_mock() + + # Setup the second testnotify2 platform dynamically + await async_load_platform( + hass, + "notify", + "testnotify2", + {}, + hass_config={"notify": [{"platform": "testnotify"}]}, + ) + await hass.async_block_till_done() + assert hass.services.has_service("testnotify2", SERVICE_RELOAD) + assert hass.services.has_service(notify.DOMAIN, "testnotify2_c") + assert hass.services.has_service(notify.DOMAIN, "testnotify2_d") + assert get_service_called.call_count == 1 + assert get_service_called.call_args[0][0] == {} + assert get_service_called.call_args[0][1] == {} + get_service_called.reset_mock() + + # Perform a reload + new_yaml_config_file = tmp_path / "configuration.yaml" + new_yaml_config = yaml.dump({"notify": [{"platform": "testnotify"}]}) + new_yaml_config_file.write_text(new_yaml_config) + + with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): + await hass.services.async_call( + "testnotify", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.services.async_call( + "testnotify2", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # Check if the notify services from setup still exist + assert hass.services.has_service(notify.DOMAIN, "testnotify_a") + assert hass.services.has_service(notify.DOMAIN, "testnotify_b") + assert get_service_called.call_count == 1 + assert get_service_called.call_args[0][0] == {"platform": "testnotify"} + assert get_service_called.call_args[0][1] is None + + # Check if the dynamically notify services from setup were removed + assert not hass.services.has_service(notify.DOMAIN, "testnotify2_c") + assert not hass.services.has_service(notify.DOMAIN, "testnotify2_d") + + +async def test_setup_platform_before_notify_setup( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """Test trying to setup a platform before legacy notify service is setup.""" + get_service_called = Mock() + + async def async_get_service(hass, config, discovery_info=None): + """Get notify service for mocked platform.""" + get_service_called(config, discovery_info) + targetlist = {"a": 1, "b": 2} + return NotificationService(hass, targetlist, "testnotify") + + async def async_get_service2(hass, config, discovery_info=None): + """Get notify service for mocked platform.""" + get_service_called(config, discovery_info) + targetlist = {"c": 3, "d": 4} + return NotificationService(hass, targetlist, "testnotify2") + + # Mock first platform + mock_notify_platform( + hass, tmp_path, "testnotify", async_get_service=async_get_service + ) + + # Initialize a second platform testnotify2 + mock_notify_platform( + hass, tmp_path, "testnotify2", async_get_service=async_get_service2 + ) + + hass_config = {"notify": [{"platform": "testnotify"}]} + + # Setup the second testnotify2 platform from discovery + load_coro = async_load_platform( + hass, Platform.NOTIFY, "testnotify2", {}, hass_config=hass_config + ) + + # Setup the testnotify platform + setup_coro = async_setup_component(hass, "notify", hass_config) + + load_task = asyncio.create_task(load_coro) + setup_task = asyncio.create_task(setup_coro) + + await asyncio.gather(load_task, setup_task) + + await hass.async_block_till_done() + assert hass.services.has_service(notify.DOMAIN, "testnotify_a") + assert hass.services.has_service(notify.DOMAIN, "testnotify_b") + assert hass.services.has_service(notify.DOMAIN, "testnotify2_c") + assert hass.services.has_service(notify.DOMAIN, "testnotify2_d") + + +async def test_setup_platform_after_notify_setup( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """Test trying to setup a platform after legacy notify service is set up.""" + get_service_called = Mock() + + async def async_get_service(hass, config, discovery_info=None): + """Get notify service for mocked platform.""" + get_service_called(config, discovery_info) + targetlist = {"a": 1, "b": 2} + return NotificationService(hass, targetlist, "testnotify") + + async def async_get_service2(hass, config, discovery_info=None): + """Get notify service for mocked platform.""" + get_service_called(config, discovery_info) + targetlist = {"c": 3, "d": 4} + return NotificationService(hass, targetlist, "testnotify2") + + # Mock first platform + mock_notify_platform( + hass, tmp_path, "testnotify", async_get_service=async_get_service + ) + + # Initialize a second platform testnotify2 + mock_notify_platform( + hass, tmp_path, "testnotify2", async_get_service=async_get_service2 + ) + + hass_config = {"notify": [{"platform": "testnotify"}]} + + # Setup the second testnotify2 platform from discovery + load_coro = async_load_platform( + hass, Platform.NOTIFY, "testnotify2", {}, hass_config=hass_config + ) + + # Setup the testnotify platform + setup_coro = async_setup_component(hass, "notify", hass_config) + + setup_task = asyncio.create_task(setup_coro) + load_task = asyncio.create_task(load_coro) + + await asyncio.gather(load_task, setup_task) + + await hass.async_block_till_done() + assert hass.services.has_service(notify.DOMAIN, "testnotify_a") + assert hass.services.has_service(notify.DOMAIN, "testnotify_b") + assert hass.services.has_service(notify.DOMAIN, "testnotify2_c") + assert hass.services.has_service(notify.DOMAIN, "testnotify2_d") + + async def test_sending_none_message(hass: HomeAssistant, tmp_path: Path) -> None: """Test send with None as message.""" send_message_mock = await help_setup_notify(hass, tmp_path) From 84a975b61ee16964929a8a15381da7d7f803dd0c Mon Sep 17 00:00:00 2001 From: Toni Korhonen Date: Sat, 13 Apr 2024 11:44:02 +0300 Subject: [PATCH 61/64] Add Balboa spa temperature range state control (high/low) (#115285) * Add temperature range switch (high/low) to Balboa spa integration. * Change Balboa spa integration temperature range control from switch to select * Balboa spa integration: Fix ruff formatting * Balboa spa integration: increase test coverage * Balboa spa integration review fixes: Move instance attributes as class attributes. Fix code comments. --- homeassistant/components/balboa/__init__.py | 8 +- homeassistant/components/balboa/icons.json | 5 ++ homeassistant/components/balboa/select.py | 52 ++++++++++++ homeassistant/components/balboa/strings.json | 9 +++ tests/components/balboa/conftest.py | 3 +- tests/components/balboa/test_select.py | 85 ++++++++++++++++++++ 6 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/balboa/select.py create mode 100644 tests/components/balboa/test_select.py diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index d6a80e8fa8f..7e220bd46f8 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -18,7 +18,13 @@ from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.FAN, Platform.LIGHT] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.FAN, + Platform.LIGHT, + Platform.SELECT, +] KEEP_ALIVE_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/balboa/icons.json b/homeassistant/components/balboa/icons.json index 7261f71bd00..7454366f692 100644 --- a/homeassistant/components/balboa/icons.json +++ b/homeassistant/components/balboa/icons.json @@ -27,6 +27,11 @@ "off": "mdi:pump-off" } } + }, + "select": { + "temperature_range": { + "default": "mdi:thermometer-lines" + } } } } diff --git a/homeassistant/components/balboa/select.py b/homeassistant/components/balboa/select.py new file mode 100644 index 00000000000..3fdd8c4d014 --- /dev/null +++ b/homeassistant/components/balboa/select.py @@ -0,0 +1,52 @@ +"""Support for Spa Client selects.""" + +from pybalboa import SpaClient, SpaControl +from pybalboa.enums import LowHighRange + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import BalboaEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the spa select entity.""" + spa: SpaClient = hass.data[DOMAIN][entry.entry_id] + async_add_entities([BalboaTempRangeSelectEntity(spa.temperature_range)]) + + +class BalboaTempRangeSelectEntity(BalboaEntity, SelectEntity): + """Representation of a Temperature Range select.""" + + _attr_icon = "mdi:thermometer-lines" + _attr_name = "Temperature range" + _attr_unique_id = "temperature_range" + _attr_translation_key = "temperature_range" + _attr_options = [ + LowHighRange.LOW.name.lower(), + LowHighRange.HIGH.name.lower(), + ] + + def __init__(self, control: SpaControl) -> None: + """Initialise the select.""" + super().__init__(control.client, "TempHiLow") + self._control = control + + @property + def current_option(self) -> str | None: + """Return current select option.""" + if self._control.state == LowHighRange.HIGH: + return LowHighRange.HIGH.name.lower() + return LowHighRange.LOW.name.lower() + + async def async_select_option(self, option: str) -> None: + """Select temperature range high/low mode.""" + if option == LowHighRange.HIGH.name.lower(): + await self._client.set_temperature_range(LowHighRange.HIGH) + else: + await self._client.set_temperature_range(LowHighRange.LOW) diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 3c8f82764d4..6ced7dfd8c3 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -65,6 +65,15 @@ "only_light": { "name": "Light" } + }, + "select": { + "temperature_range": { + "name": "Temperature range", + "state": { + "low": "Low", + "high": "High" + } + } } } } diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index fce022572c3..7f679773f93 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Generator from unittest.mock import AsyncMock, MagicMock, patch -from pybalboa.enums import HeatMode +from pybalboa.enums import HeatMode, LowHighRange import pytest from homeassistant.core import HomeAssistant @@ -60,5 +60,6 @@ def client_fixture() -> Generator[MagicMock, None, None]: client.heat_state = 2 client.lights = [] client.pumps = [] + client.temperature_range.state = LowHighRange.LOW yield client diff --git a/tests/components/balboa/test_select.py b/tests/components/balboa/test_select.py new file mode 100644 index 00000000000..bd79f024817 --- /dev/null +++ b/tests/components/balboa/test_select.py @@ -0,0 +1,85 @@ +"""Tests of the select entity of the balboa integration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, call + +from pybalboa import SpaControl +from pybalboa.enums import LowHighRange +import pytest + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import client_update, init_integration + +ENTITY_SELECT = "select.fakespa_temperature_range" + + +@pytest.fixture +def mock_select(client: MagicMock): + """Return a mock switch.""" + select = MagicMock(SpaControl) + + async def set_state(state: LowHighRange): + select.state = state # mock the spacontrol state + + select.client = client + select.state = LowHighRange.LOW + select.set_state = set_state + client.temperature_range = select + return select + + +async def test_select(hass: HomeAssistant, client: MagicMock, mock_select) -> None: + """Test spa temperature range select.""" + await init_integration(hass) + + # check if the initial state is off + state = hass.states.get(ENTITY_SELECT) + assert state.state == LowHighRange.LOW.name.lower() + + # test high state + await _select_option_and_wait(hass, ENTITY_SELECT, LowHighRange.HIGH.name.lower()) + assert client.set_temperature_range.call_count == 1 + assert client.set_temperature_range.call_args == call(LowHighRange.HIGH) + + # test back to low state + await _select_option_and_wait(hass, ENTITY_SELECT, LowHighRange.LOW.name.lower()) + assert client.set_temperature_range.call_count == 2 # total call count + assert client.set_temperature_range.call_args == call(LowHighRange.LOW) + + +async def test_selected_option( + hass: HomeAssistant, client: MagicMock, mock_select +) -> None: + """Test spa temperature range selected option.""" + + await init_integration(hass) + + # ensure initial low state + state = hass.states.get(ENTITY_SELECT) + assert state.state == LowHighRange.LOW.name.lower() + + # ensure high state + mock_select.state = LowHighRange.HIGH + state = await client_update(hass, client, ENTITY_SELECT) + assert state.state == LowHighRange.HIGH.name.lower() + + +async def _select_option_and_wait(hass: HomeAssistant | None, entity, option): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity, + ATTR_OPTION: option, + }, + blocking=True, + ) + await hass.async_block_till_done() From 27f6a7de43420ff32b990c41af256467fc01d143 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 13 Apr 2024 10:48:34 +0200 Subject: [PATCH 62/64] Revert mypy_config formatting (#115518) --- script/hassfest/mypy_config.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index c6c5907cdb9..76fe47837e4 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -43,8 +43,20 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { "warn_redundant_casts": "true", "warn_unused_configs": "true", "warn_unused_ignores": "true", - "enable_error_code": "ignore-without-code, redundant-self, truthy-iterable", - "disable_error_code": "annotation-unchecked, import-not-found, import-untyped", + "enable_error_code": ", ".join( # noqa: FLY002 + [ + "ignore-without-code", + "redundant-self", + "truthy-iterable", + ] + ), + "disable_error_code": ", ".join( # noqa: FLY002 + [ + "annotation-unchecked", + "import-not-found", + "import-untyped", + ] + ), # Impractical in real code # E.g. this breaks passthrough ParamSpec typing with Concatenate "extra_checks": "false", From 38c7b99aef304db3e1f0a3b7705eae012db835ba Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 13 Apr 2024 12:58:31 +0200 Subject: [PATCH 63/64] Make legacy notify group tests independent of demo platform (#115494) --- tests/components/group/test_notify.py | 214 +++++++++++++++++--------- 1 file changed, 139 insertions(+), 75 deletions(-) diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index 5709e648508..2f9afdf5aa5 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -1,31 +1,91 @@ """The tests for the notify.group platform.""" -from unittest.mock import MagicMock, patch +from collections.abc import Mapping +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, call, patch from homeassistant import config as hass_config from homeassistant.components import notify -import homeassistant.components.demo.notify as demo from homeassistant.components.group import SERVICE_RELOAD import homeassistant.components.group.notify as group from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component -from tests.common import get_fixture_path +from tests.common import MockPlatform, get_fixture_path, mock_platform -async def test_send_message_with_data(hass: HomeAssistant) -> None: +class MockNotifyPlatform(MockPlatform): + """Help to set up a legacy test notify platform.""" + + def __init__(self, async_get_service: Any) -> None: + """Initialize platform.""" + super().__init__() + self.async_get_service = async_get_service + + +def mock_notify_platform( + hass: HomeAssistant, + tmp_path: Path, + async_get_service: Any = None, +): + """Specialize the mock platform for legacy notify service.""" + loaded_platform = MockNotifyPlatform(async_get_service) + mock_platform(hass, "test.notify", loaded_platform) + + return loaded_platform + + +async def help_setup_notify( + hass: HomeAssistant, + tmp_path: Path, + targets: dict[str, None] | None = None, + group_setup: list[dict[str, None]] | None = None, +) -> MagicMock: + """Help set up a platform notify service.""" + send_message_mock = MagicMock() + + class _TestNotifyService(notify.BaseNotificationService): + def __init__(self, targets: dict[str, None] | None) -> None: + """Initialize service.""" + self._targets = targets + super().__init__() + + @property + def targets(self) -> Mapping[str, Any] | None: + """Return a dictionary of registered targets.""" + return self._targets + + def send_message(self, message: str, **kwargs: Any) -> None: + """Send a message.""" + send_message_mock(message, kwargs) + + async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> notify.BaseNotificationService: + """Get notify service for mocked platform.""" + return _TestNotifyService(targets) + + # Mock platform with service + mock_notify_platform(hass, tmp_path, async_get_service=async_get_service) + # Setup the platform + items: list[dict[str, Any]] = [{"platform": "test"}] + items.extend(group_setup or []) + await async_setup_component(hass, "notify", {"notify": items}) + await hass.async_block_till_done() + + # Return mock for assertion service calls + return send_message_mock + + +async def test_send_message_with_data(hass: HomeAssistant, tmp_path: Path) -> None: """Test sending a message with to a notify group.""" - service1 = demo.DemoNotificationService(hass) - service2 = demo.DemoNotificationService(hass) - - service1.send_message = MagicMock(autospec=True) - service2.send_message = MagicMock(autospec=True) - - def mock_get_service(hass, config, discovery_info=None): - if config["name"] == "demo1": - return service1 - return service2 - + send_message_mock = await help_setup_notify( + hass, tmp_path, {"service1": 1, "service2": 2} + ) assert await async_setup_component( hass, "group", @@ -33,26 +93,13 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - with patch.object(demo, "get_service", mock_get_service): - await async_setup_component( - hass, - notify.DOMAIN, - { - "notify": [ - {"name": "demo1", "platform": "demo"}, - {"name": "demo2", "platform": "demo"}, - ] - }, - ) - await hass.async_block_till_done() - service = await group.async_get_service( hass, { "services": [ - {"service": "demo1"}, + {"service": "test_service1"}, { - "service": "demo2", + "service": "test_service2", "data": { "target": "unnamed device", "data": {"test": "message", "default": "default"}, @@ -62,26 +109,35 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None: }, ) - """Test sending a message to a notify group.""" + # Test sending a message to a notify group. await service.async_send_message( "Hello", title="Test notification", data={"hello": "world"} ) await hass.async_block_till_done() + send_message_mock.assert_has_calls( + [ + call( + "Hello", + { + "title": "Test notification", + "target": [1], + "data": {"hello": "world"}, + }, + ), + call( + "Hello", + { + "title": "Test notification", + "target": [2], + "data": {"hello": "world", "test": "message", "default": "default"}, + }, + ), + ] + ) + send_message_mock.reset_mock() - assert service1.send_message.mock_calls[0][1][0] == "Hello" - assert service1.send_message.mock_calls[0][2] == { - "title": "Test notification", - "data": {"hello": "world"}, - } - assert service2.send_message.mock_calls[0][1][0] == "Hello" - assert service2.send_message.mock_calls[0][2] == { - "target": ["unnamed device"], - "title": "Test notification", - "data": {"hello": "world", "test": "message", "default": "default"}, - } - - """Test sending a message which overrides service defaults to a notify group.""" + # Test sending a message which overrides service defaults to a notify group await service.async_send_message( "Hello", title="Test notification", @@ -90,22 +146,34 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None: await hass.async_block_till_done() - assert service1.send_message.mock_calls[1][1][0] == "Hello" - assert service1.send_message.mock_calls[1][2] == { - "title": "Test notification", - "data": {"hello": "world", "default": "override"}, - } - assert service2.send_message.mock_calls[1][1][0] == "Hello" - assert service2.send_message.mock_calls[1][2] == { - "target": ["unnamed device"], - "title": "Test notification", - "data": {"hello": "world", "test": "message", "default": "override"}, - } + send_message_mock.assert_has_calls( + [ + call( + "Hello", + { + "title": "Test notification", + "target": [1], + "data": {"hello": "world", "default": "override"}, + }, + ), + call( + "Hello", + { + "title": "Test notification", + "target": [2], + "data": { + "hello": "world", + "test": "message", + "default": "override", + }, + }, + ), + ] + ) -async def test_reload_notify(hass: HomeAssistant) -> None: +async def test_reload_notify(hass: HomeAssistant, tmp_path: Path) -> None: """Verify we can reload the notify service.""" - assert await async_setup_component( hass, "group", @@ -113,25 +181,21 @@ async def test_reload_notify(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert await async_setup_component( + await help_setup_notify( hass, - notify.DOMAIN, - { - notify.DOMAIN: [ - {"name": "demo1", "platform": "demo"}, - {"name": "demo2", "platform": "demo"}, - { - "name": "group_notify", - "platform": "group", - "services": [{"service": "demo1"}], - }, - ] - }, + tmp_path, + {"service1": 1, "service2": 2}, + [ + { + "name": "group_notify", + "platform": "group", + "services": [{"service": "test_service1"}], + } + ], ) - await hass.async_block_till_done() - assert hass.services.has_service(notify.DOMAIN, "demo1") - assert hass.services.has_service(notify.DOMAIN, "demo2") + assert hass.services.has_service(notify.DOMAIN, "test_service1") + assert hass.services.has_service(notify.DOMAIN, "test_service2") assert hass.services.has_service(notify.DOMAIN, "group_notify") yaml_path = get_fixture_path("configuration.yaml", "group") @@ -145,7 +209,7 @@ async def test_reload_notify(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert hass.services.has_service(notify.DOMAIN, "demo1") - assert hass.services.has_service(notify.DOMAIN, "demo2") + assert hass.services.has_service(notify.DOMAIN, "test_service1") + assert hass.services.has_service(notify.DOMAIN, "test_service2") assert not hass.services.has_service(notify.DOMAIN, "group_notify") assert hass.services.has_service(notify.DOMAIN, "new_group_notify") From 5e8b46c670153d11ead7e660101cc1b586981873 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 13 Apr 2024 13:04:39 +0200 Subject: [PATCH 64/64] Make color extractor single config entry (#115016) * Make color extractor single config entry * Make color extractor single config entry * Fix --- homeassistant/components/color_extractor/config_flow.py | 4 ---- homeassistant/components/color_extractor/manifest.json | 3 ++- homeassistant/components/color_extractor/strings.json | 3 --- homeassistant/generated/integrations.json | 3 ++- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/color_extractor/config_flow.py b/homeassistant/components/color_extractor/config_flow.py index aacb07d8982..aab56eb9537 100644 --- a/homeassistant/components/color_extractor/config_flow.py +++ b/homeassistant/components/color_extractor/config_flow.py @@ -18,10 +18,6 @@ class ColorExtractorConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - if user_input is not None: return self.async_create_entry(title=DEFAULT_NAME, data={}) - return self.async_show_form(step_id="user") diff --git a/homeassistant/components/color_extractor/manifest.json b/homeassistant/components/color_extractor/manifest.json index c87ac2540a6..a86adaac495 100644 --- a/homeassistant/components/color_extractor/manifest.json +++ b/homeassistant/components/color_extractor/manifest.json @@ -4,5 +4,6 @@ "codeowners": ["@GenericStudent"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/color_extractor", - "requirements": ["colorthief==0.2.1"] + "requirements": ["colorthief==0.2.1"], + "single_config_entry": true } diff --git a/homeassistant/components/color_extractor/strings.json b/homeassistant/components/color_extractor/strings.json index f66c448f7c2..e501879e881 100644 --- a/homeassistant/components/color_extractor/strings.json +++ b/homeassistant/components/color_extractor/strings.json @@ -4,9 +4,6 @@ "user": { "description": "[%key:common::config_flow::description::confirm_setup%]" } - }, - "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, "services": { diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 667639226a1..20fbc883207 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -957,7 +957,8 @@ "color_extractor": { "name": "ColorExtractor", "integration_type": "hub", - "config_flow": true + "config_flow": true, + "single_config_entry": true }, "comed": { "name": "Commonwealth Edison (ComEd)",