From 05fd64fe802b82a04888244e65e012611d8eb912 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Oct 2023 17:41:53 +0200 Subject: [PATCH 001/643] Bumped version to 2023.11.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 77c5582464e..5b8e9f43b21 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 82bb7d08e26..8068b7e55b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0.dev0" +version = "2023.11.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5b0e0b07b3362a3615cfc586084397f5cea696d4 Mon Sep 17 00:00:00 2001 From: Amit Finkelstein Date: Thu, 26 Oct 2023 11:46:20 +0300 Subject: [PATCH 002/643] Apple TV: Use replacement commands for deprecated ones (#102056) Co-authored-by: Robert Resch --- homeassistant/components/apple_tv/remote.py | 17 ++++++++++++- tests/components/apple_tv/test_remote.py | 28 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 tests/components/apple_tv/test_remote.py diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index f3be6977891..bab3421c58d 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -21,6 +21,15 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +COMMAND_TO_ATTRIBUTE = { + "wakeup": ("power", "turn_on"), + "suspend": ("power", "turn_off"), + "turn_on": ("power", "turn_on"), + "turn_off": ("power", "turn_off"), + "volume_up": ("audio", "volume_up"), + "volume_down": ("audio", "volume_down"), + "home_hold": ("remote_control", "home"), +} async def async_setup_entry( @@ -61,7 +70,13 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): for _ in range(num_repeats): for single_command in command: - attr_value = getattr(self.atv.remote_control, single_command, None) + attr_value = None + if attributes := COMMAND_TO_ATTRIBUTE.get(single_command): + attr_value = self.atv + for attr_name in attributes: + attr_value = getattr(attr_value, attr_name, None) + if not attr_value: + attr_value = getattr(self.atv.remote_control, single_command, None) if not attr_value: raise ValueError("Command not found. Exiting sequence") diff --git a/tests/components/apple_tv/test_remote.py b/tests/components/apple_tv/test_remote.py new file mode 100644 index 00000000000..db2a4964f6c --- /dev/null +++ b/tests/components/apple_tv/test_remote.py @@ -0,0 +1,28 @@ +"""Test apple_tv remote.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.apple_tv.remote import AppleTVRemote +from homeassistant.components.remote import ATTR_DELAY_SECS, ATTR_NUM_REPEATS + + +@pytest.mark.parametrize( + ("command", "method"), + [ + ("up", "remote_control.up"), + ("wakeup", "power.turn_on"), + ("volume_up", "audio.volume_up"), + ("home_hold", "remote_control.home"), + ], + ids=["up", "wakeup", "volume_up", "home_hold"], +) +async def test_send_command(command: str, method: str) -> None: + """Test "send_command" method.""" + remote = AppleTVRemote("test", "test", None) + remote.atv = AsyncMock() + await remote.async_send_command( + [command], **{ATTR_NUM_REPEATS: 1, ATTR_DELAY_SECS: 0} + ) + assert len(remote.atv.method_calls) == 1 + assert str(remote.atv.method_calls[0]) == f"call.{method}()" From bbcfb5f30e46e27094ba207aeb888452aeebcf15 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Oct 2023 21:34:59 +0200 Subject: [PATCH 003/643] Improve exception handling for Vodafone Station (#102761) * improve exception handling for Vodafone Station * address review comment * apply review comment * better except handling (bump library) * cleanup --- .../vodafone_station/coordinator.py | 22 +++++++++++-------- .../components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 38fc80ac3af..a2cddcf9a65 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -95,15 +95,19 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Update router data.""" _LOGGER.debug("Polling Vodafone Station host: %s", self._host) try: - logged = await self.api.login() - except exceptions.CannotConnect as err: - _LOGGER.warning("Connection error for %s", self._host) - raise UpdateFailed(f"Error fetching data: {repr(err)}") from err - except exceptions.CannotAuthenticate as err: - raise ConfigEntryAuthFailed from err - - if not logged: - raise ConfigEntryAuthFailed + try: + await self.api.login() + except exceptions.CannotAuthenticate as err: + raise ConfigEntryAuthFailed from err + except ( + exceptions.CannotConnect, + exceptions.AlreadyLogged, + exceptions.GenericLoginError, + ) as err: + raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + except (ConfigEntryAuthFailed, UpdateFailed): + await self.api.close() + raise utc_point_in_time = dt_util.utcnow() data_devices = { diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 628c25b987e..2a1814c83d0 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.4.1"] + "requirements": ["aiovodafone==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7d5f24f0b82..aa93dd46f26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -375,7 +375,7 @@ aiounifi==64 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.1 +aiovodafone==0.4.2 # homeassistant.components.waqi aiowaqi==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e46c62e8976..dc8a40bc325 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aiounifi==64 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.1 +aiovodafone==0.4.2 # homeassistant.components.waqi aiowaqi==2.1.0 From 62733e830f2fd55a1d74d1534901ddebcde2e8d1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 09:46:16 +0200 Subject: [PATCH 004/643] Improve validation of device automations (#102766) * Improve validation of device automations * Improve comments * Address review comment --- .../components/device_automation/helpers.py | 37 +++-- .../components/device_automation/test_init.py | 148 +++++++++++++----- 2 files changed, 133 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 83c599bc65d..a00455293f6 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -5,9 +5,9 @@ from typing import cast import voluptuous as vol -from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, Platform +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from . import DeviceAutomationType, async_get_device_automation_platform @@ -55,31 +55,42 @@ async def async_validate_device_automation_config( platform = await async_get_device_automation_platform( hass, validated_config[CONF_DOMAIN], automation_type ) + + # Make sure the referenced device and optional entity exist + device_registry = dr.async_get(hass) + if not (device := device_registry.async_get(validated_config[CONF_DEVICE_ID])): + # The device referenced by the device automation does not exist + raise InvalidDeviceAutomationConfig( + f"Unknown device '{validated_config[CONF_DEVICE_ID]}'" + ) + if entity_id := validated_config.get(CONF_ENTITY_ID): + try: + er.async_validate_entity_id(er.async_get(hass), entity_id) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig( + f"Unknown entity '{entity_id}'" + ) from err + if not hasattr(platform, DYNAMIC_VALIDATOR[automation_type]): # Pass the unvalidated config to avoid mutating the raw config twice return cast( ConfigType, getattr(platform, STATIC_VALIDATOR[automation_type])(config) ) - # Bypass checks for entity platforms + # Devices are not linked to config entries from entity platform domains, skip + # the checks below which look for a config entry matching the device automation + # domain if ( automation_type == DeviceAutomationType.ACTION and validated_config[CONF_DOMAIN] in ENTITY_PLATFORMS ): + # Pass the unvalidated config to avoid mutating the raw config twice return cast( ConfigType, await getattr(platform, DYNAMIC_VALIDATOR[automation_type])(hass, config), ) - # Only call the dynamic validator if the referenced device exists and the relevant - # config entry is loaded - registry = dr.async_get(hass) - if not (device := registry.async_get(validated_config[CONF_DEVICE_ID])): - # The device referenced by the device automation does not exist - raise InvalidDeviceAutomationConfig( - f"Unknown device '{validated_config[CONF_DEVICE_ID]}'" - ) - + # Find a config entry with the same domain as the device automation device_config_entry = None for entry_id in device.config_entries: if ( @@ -91,7 +102,7 @@ async def async_validate_device_automation_config( break if not device_config_entry: - # The config entry referenced by the device automation does not exist + # There's no config entry with the same domain as the device automation raise InvalidDeviceAutomationConfig( f"Device '{validated_config[CONF_DEVICE_ID]}' has no config entry from " f"domain '{validated_config[CONF_DOMAIN]}'" diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 3a7105684f4..457b7ccbf9b 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,6 +1,7 @@ """The test for light device automation.""" from unittest.mock import AsyncMock, Mock, patch +import attr import pytest from pytest_unordered import unordered import voluptuous as vol @@ -31,6 +32,13 @@ from tests.common import ( from tests.typing import WebSocketGenerator +@attr.s(frozen=True) +class MockDeviceEntry(dr.DeviceEntry): + """Device Registry Entry with fixed UUID.""" + + id: str = attr.ib(default="very_unique") + + @pytest.fixture(autouse=True, name="stub_blueprint_populate") def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" @@ -1240,17 +1248,56 @@ async def test_automation_with_integration_without_device_trigger( ) +BAD_AUTOMATIONS = [ + ( + {"device_id": "very_unique", "domain": "light"}, + "required key not provided @ data['entity_id']", + ), + ( + {"device_id": "wrong", "domain": "light"}, + "Unknown device 'wrong'", + ), + ( + {"device_id": "wrong"}, + "required key not provided @ data{path}['domain']", + ), + ( + {"device_id": "wrong", "domain": "light"}, + "Unknown device 'wrong'", + ), + ( + {"device_id": "very_unique", "domain": "light"}, + "required key not provided @ data['entity_id']", + ), + ( + {"device_id": "very_unique", "domain": "light", "entity_id": "wrong"}, + "Unknown entity 'wrong'", + ), +] + +BAD_TRIGGERS = BAD_CONDITIONS = BAD_AUTOMATIONS + [ + ( + {"domain": "light"}, + "required key not provided @ data{path}['device_id']", + ) +] + + +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("action", "expected_error"), BAD_AUTOMATIONS) async def test_automation_with_bad_action( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + action: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device action.""" config_entry = MockConfigEntry(domain="fake_integration", data={}) config_entry.state = config_entries.ConfigEntryState.LOADED config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -1262,25 +1309,29 @@ async def test_automation_with_bad_action( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": {"device_id": device_entry.id, "domain": "light"}, + "action": action, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="['action'][0]") in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_condition_action( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device action.""" config_entry = MockConfigEntry(domain="fake_integration", data={}) config_entry.state = config_entries.ConfigEntryState.LOADED config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -1292,42 +1343,32 @@ async def test_automation_with_bad_condition_action( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": { - "condition": "device", - "device_id": device_entry.id, - "domain": "light", - }, + "action": {"condition": "device"} | condition, } }, ) - assert "required key not provided" in caplog.text - - -async def test_automation_with_bad_condition_missing_domain( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test automation with bad device condition.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "hello", - "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": {"condition": "device", "device_id": "hello.device"}, - "action": {"service": "test.automation", "entity_id": "hello.world"}, - } - }, - ) - - assert "required key not provided @ data['condition'][0]['domain']" in caplog.text + assert expected_error.format(path="['action'][0]") in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device condition.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1335,13 +1376,13 @@ async def test_automation_with_bad_condition( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": {"condition": "device", "domain": "light"}, + "condition": {"condition": "device"} | condition, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="['condition'][0]") in caplog.text @pytest.fixture @@ -1475,10 +1516,24 @@ async def test_automation_with_sub_condition( ) +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_sub_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device condition under and/or conditions.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1488,33 +1543,48 @@ async def test_automation_with_bad_sub_condition( "trigger": {"platform": "event", "event_type": "test_event1"}, "condition": { "condition": "and", - "conditions": [{"condition": "device", "domain": "light"}], + "conditions": [{"condition": "device"} | condition], }, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + path = "['condition'][0]['conditions'][0]" + assert expected_error.format(path=path) in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("trigger", "expected_error"), BAD_TRIGGERS) async def test_automation_with_bad_trigger( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + trigger: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device trigger.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, { automation.DOMAIN: { "alias": "hello", - "trigger": {"platform": "device", "domain": "light"}, + "trigger": {"platform": "device"} | trigger, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="") in caplog.text async def test_websocket_device_not_found( From 0a0584b0533625be58ac73e732474a11d2717199 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Oct 2023 23:09:36 +0200 Subject: [PATCH 005/643] Fix velbus import (#102780) --- homeassistant/components/velbus/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 5c35303f859..1888a177895 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -import velbusaio +import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol From 5fe5013198965a86b9f99228bb2774770479b1c6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 26 Oct 2023 09:43:10 -0700 Subject: [PATCH 006/643] Change todo move API to reference previous uid (#102795) --- homeassistant/components/local_todo/todo.py | 28 +++++--- .../components/shopping_list/__init__.py | 24 ++++--- .../components/shopping_list/todo.py | 6 +- homeassistant/components/todo/__init__.py | 18 +++-- pylint/plugins/hass_enforce_type_hints.py | 2 +- tests/components/local_todo/test_todo.py | 72 ++++++++++++++----- tests/components/shopping_list/test_todo.py | 35 +++++---- tests/components/todo/test_init.py | 15 ++-- 8 files changed, 131 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 14d14316faf..7e23d01ee46 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -139,20 +139,28 @@ class LocalTodoListEntity(TodoListEntity): await self._async_save() await self.async_update_ha_state(force_refresh=True) - async def async_move_todo_item(self, uid: str, pos: int) -> None: + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: """Re-order an item to the To-do list.""" + if uid == previous_uid: + return todos = self._calendar.todos - found_item: Todo | None = None - for idx, itm in enumerate(todos): - if itm.uid == uid: - found_item = itm - todos.pop(idx) - break - if found_item is None: + item_idx: dict[str, int] = {itm.uid: idx for idx, itm in enumerate(todos)} + if uid not in item_idx: raise HomeAssistantError( - f"Item '{uid}' not found in todo list {self.entity_id}" + "Item '{uid}' not found in todo list {self.entity_id}" ) - todos.insert(pos, found_item) + if previous_uid and previous_uid not in item_idx: + raise HomeAssistantError( + "Item '{previous_uid}' not found in todo list {self.entity_id}" + ) + dst_idx = item_idx[previous_uid] + 1 if previous_uid else 0 + src_idx = item_idx[uid] + src_item = todos.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + todos.insert(dst_idx, src_item) await self._async_save() await self.async_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index f2de59b10af..e2f04b5d880 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -322,17 +322,23 @@ class ShoppingData: context=context, ) - async def async_move_item(self, uid: str, pos: int) -> None: + async def async_move_item(self, uid: str, previous: str | None = None) -> None: """Re-order a shopping list item.""" - found_item: dict[str, Any] | None = None - for idx, itm in enumerate(self.items): - if cast(str, itm["id"]) == uid: - found_item = itm - self.items.pop(idx) - break - if not found_item: + if uid == previous: + return + item_idx = {cast(str, itm["id"]): idx for idx, itm in enumerate(self.items)} + if uid not in item_idx: raise NoMatchingShoppingListItem(f"Item '{uid}' not found in shopping list") - self.items.insert(pos, found_item) + if previous and previous not in item_idx: + raise NoMatchingShoppingListItem( + f"Item '{previous}' not found in shopping list" + ) + dst_idx = item_idx[previous] + 1 if previous else 0 + src_idx = item_idx[uid] + src_item = self.items.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + self.items.insert(dst_idx, src_item) await self.hass.async_add_executor_job(self.save) self._async_notify() self.hass.bus.async_fire( diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index 53c9e6b6d74..d89f376d662 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -71,11 +71,13 @@ class ShoppingTodoListEntity(TodoListEntity): """Add an item to the To-do list.""" await self._data.async_remove_items(set(uids)) - async def async_move_todo_item(self, uid: str, pos: int) -> None: + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: """Re-order an item to the To-do list.""" try: - await self._data.async_move_item(uid, pos) + await self._data.async_move_item(uid, previous_uid) except NoMatchingShoppingListItem as err: raise HomeAssistantError( f"Shopping list item '{uid}' could not be re-ordered" diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index a6660b0231a..12eac858f75 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -152,8 +152,15 @@ class TodoListEntity(Entity): """Delete an item in the To-do list.""" raise NotImplementedError() - async def async_move_todo_item(self, uid: str, pos: int) -> None: - """Move an item in the To-do list.""" + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Move an item in the To-do list. + + The To-do item with the specified `uid` should be moved to the position + in the list after the specified by `previous_uid` or `None` for the first + position in the To-do list. + """ raise NotImplementedError() @@ -190,7 +197,7 @@ async def websocket_handle_todo_item_list( vol.Required("type"): "todo/item/move", vol.Required("entity_id"): cv.entity_id, vol.Required("uid"): cv.string, - vol.Optional("pos", default=0): cv.positive_int, + vol.Optional("previous_uid"): cv.string, } ) @websocket_api.async_response @@ -215,9 +222,10 @@ async def websocket_handle_todo_item_move( ) ) return - try: - await entity.async_move_todo_item(uid=msg["uid"], pos=msg["pos"]) + await entity.async_move_todo_item( + uid=msg["uid"], previous_uid=msg.get("previous_uid") + ) except HomeAssistantError as ex: connection.send_error(msg["id"], "failed", str(ex)) else: diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 845b70b72ba..f43dd9b6672 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2469,7 +2469,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="async_move_todo_item", arg_types={ 1: "str", - 2: "int", + 2: "str | None", }, return_type="None", ), diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 6d06649a6ba..8a7e38c9773 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -59,7 +59,7 @@ async def ws_move_item( ) -> Callable[[str, str | None], Awaitable[None]]: """Fixture to move an item in the todo list.""" - async def move(uid: str, pos: int) -> None: + async def move(uid: str, previous_uid: str | None) -> None: # Fetch items using To-do platform client = await hass_ws_client() id = ws_req_id() @@ -68,8 +68,9 @@ async def ws_move_item( "type": "todo/item/move", "entity_id": TEST_ENTITY, "uid": uid, - "pos": pos, } + if previous_uid is not None: + data["previous_uid"] = previous_uid await client.send_json(data) resp = await client.receive_json() assert resp.get("id") == id @@ -237,30 +238,29 @@ async def test_update_item( @pytest.mark.parametrize( - ("src_idx", "pos", "expected_items"), + ("src_idx", "dst_idx", "expected_items"), [ # Move any item to the front of the list - (0, 0, ["item 1", "item 2", "item 3", "item 4"]), - (1, 0, ["item 2", "item 1", "item 3", "item 4"]), - (2, 0, ["item 3", "item 1", "item 2", "item 4"]), - (3, 0, ["item 4", "item 1", "item 2", "item 3"]), + (0, None, ["item 1", "item 2", "item 3", "item 4"]), + (1, None, ["item 2", "item 1", "item 3", "item 4"]), + (2, None, ["item 3", "item 1", "item 2", "item 4"]), + (3, None, ["item 4", "item 1", "item 2", "item 3"]), # Move items right (0, 1, ["item 2", "item 1", "item 3", "item 4"]), (0, 2, ["item 2", "item 3", "item 1", "item 4"]), (0, 3, ["item 2", "item 3", "item 4", "item 1"]), (1, 2, ["item 1", "item 3", "item 2", "item 4"]), (1, 3, ["item 1", "item 3", "item 4", "item 2"]), - (1, 4, ["item 1", "item 3", "item 4", "item 2"]), - (1, 5, ["item 1", "item 3", "item 4", "item 2"]), # Move items left - (2, 1, ["item 1", "item 3", "item 2", "item 4"]), - (3, 1, ["item 1", "item 4", "item 2", "item 3"]), - (3, 2, ["item 1", "item 2", "item 4", "item 3"]), + (2, 0, ["item 1", "item 3", "item 2", "item 4"]), + (3, 0, ["item 1", "item 4", "item 2", "item 3"]), + (3, 1, ["item 1", "item 2", "item 4", "item 3"]), # No-ops - (1, 1, ["item 1", "item 2", "item 3", "item 4"]), + (0, 0, ["item 1", "item 2", "item 3", "item 4"]), + (2, 1, ["item 1", "item 2", "item 3", "item 4"]), (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 2, ["item 1", "item 2", "item 3", "item 4"]), (3, 3, ["item 1", "item 2", "item 3", "item 4"]), - (3, 4, ["item 1", "item 2", "item 3", "item 4"]), ], ) async def test_move_item( @@ -269,7 +269,7 @@ async def test_move_item( ws_get_items: Callable[[], Awaitable[dict[str, str]]], ws_move_item: Callable[[str, str | None], Awaitable[None]], src_idx: int, - pos: int, + dst_idx: int | None, expected_items: list[str], ) -> None: """Test moving a todo item within the list.""" @@ -289,7 +289,10 @@ async def test_move_item( assert summaries == ["item 1", "item 2", "item 3", "item 4"] # Prepare items for moving - await ws_move_item(uids[src_idx], pos) + previous_uid = None + if dst_idx is not None: + previous_uid = uids[dst_idx] + await ws_move_item(uids[src_idx], previous_uid) items = await ws_get_items() assert len(items) == 4 @@ -311,7 +314,42 @@ async def test_move_item_unknown( "type": "todo/item/move", "entity_id": TEST_ENTITY, "uid": "unknown", - "pos": 0, + "previous_uid": "item-2", + } + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert not resp.get("success") + assert resp.get("error", {}).get("code") == "failed" + assert "not found in todo list" in resp["error"]["message"] + + +async def test_move_item_previous_unknown( + hass: HomeAssistant, + setup_integration: None, + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test moving a todo item that does not exist.""" + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "item 1"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + items = await ws_get_items() + assert len(items) == 1 + + # Prepare items for moving + client = await hass_ws_client() + data = { + "id": 1, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": items[0]["uid"], + "previous_uid": "unknown", } await client.send_json(data) resp = await client.receive_json() diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 15f1e50bdb9..ab28c6cbe6d 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -57,10 +57,10 @@ async def ws_get_items( async def ws_move_item( hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int], -) -> Callable[[str, int | None], Awaitable[None]]: +) -> Callable[[str, str | None], Awaitable[None]]: """Fixture to move an item in the todo list.""" - async def move(uid: str, pos: int | None) -> dict[str, Any]: + async def move(uid: str, previous_uid: str | None) -> dict[str, Any]: # Fetch items using To-do platform client = await hass_ws_client() id = ws_req_id() @@ -70,8 +70,8 @@ async def ws_move_item( "entity_id": TEST_ENTITY, "uid": uid, } - if pos is not None: - data["pos"] = pos + if previous_uid is not None: + data["previous_uid"] = previous_uid await client.send_json(data) resp = await client.receive_json() assert resp.get("id") == id @@ -406,10 +406,10 @@ async def test_update_invalid_item( ("src_idx", "dst_idx", "expected_items"), [ # Move any item to the front of the list - (0, 0, ["item 1", "item 2", "item 3", "item 4"]), - (1, 0, ["item 2", "item 1", "item 3", "item 4"]), - (2, 0, ["item 3", "item 1", "item 2", "item 4"]), - (3, 0, ["item 4", "item 1", "item 2", "item 3"]), + (0, None, ["item 1", "item 2", "item 3", "item 4"]), + (1, None, ["item 2", "item 1", "item 3", "item 4"]), + (2, None, ["item 3", "item 1", "item 2", "item 4"]), + (3, None, ["item 4", "item 1", "item 2", "item 3"]), # Move items right (0, 1, ["item 2", "item 1", "item 3", "item 4"]), (0, 2, ["item 2", "item 3", "item 1", "item 4"]), @@ -417,15 +417,15 @@ async def test_update_invalid_item( (1, 2, ["item 1", "item 3", "item 2", "item 4"]), (1, 3, ["item 1", "item 3", "item 4", "item 2"]), # Move items left - (2, 1, ["item 1", "item 3", "item 2", "item 4"]), - (3, 1, ["item 1", "item 4", "item 2", "item 3"]), - (3, 2, ["item 1", "item 2", "item 4", "item 3"]), + (2, 0, ["item 1", "item 3", "item 2", "item 4"]), + (3, 0, ["item 1", "item 4", "item 2", "item 3"]), + (3, 1, ["item 1", "item 2", "item 4", "item 3"]), # No-ops (0, 0, ["item 1", "item 2", "item 3", "item 4"]), - (1, 1, ["item 1", "item 2", "item 3", "item 4"]), + (2, 1, ["item 1", "item 2", "item 3", "item 4"]), (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 2, ["item 1", "item 2", "item 3", "item 4"]), (3, 3, ["item 1", "item 2", "item 3", "item 4"]), - (3, 4, ["item 1", "item 2", "item 3", "item 4"]), ], ) async def test_move_item( @@ -433,7 +433,7 @@ async def test_move_item( sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], - ws_move_item: Callable[[str, int | None], Awaitable[dict[str, Any]]], + ws_move_item: Callable[[str, str | None], Awaitable[dict[str, Any]]], src_idx: int, dst_idx: int | None, expected_items: list[str], @@ -457,7 +457,12 @@ async def test_move_item( summaries = [item["summary"] for item in items] assert summaries == ["item 1", "item 2", "item 3", "item 4"] - resp = await ws_move_item(uids[src_idx], dst_idx) + # Prepare items for moving + previous_uid: str | None = None + if dst_idx is not None: + previous_uid = uids[dst_idx] + + resp = await ws_move_item(uids[src_idx], previous_uid) assert resp.get("success") items = await ws_get_items() diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 833a4ea266b..f4d671ad352 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -571,7 +571,7 @@ async def test_move_todo_item_service_by_id( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() @@ -581,7 +581,7 @@ async def test_move_todo_item_service_by_id( args = test_entity.async_move_todo_item.call_args assert args assert args.kwargs.get("uid") == "item-1" - assert args.kwargs.get("pos") == 1 + assert args.kwargs.get("previous_uid") == "item-2" async def test_move_todo_item_service_raises( @@ -601,7 +601,7 @@ async def test_move_todo_item_service_raises( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() @@ -620,15 +620,10 @@ async def test_move_todo_item_service_raises( ), ({"entity_id": "todo.entity1"}, "invalid_format", "required key not provided"), ( - {"entity_id": "todo.entity1", "pos": "2"}, + {"entity_id": "todo.entity1", "previous_uid": "item-2"}, "invalid_format", "required key not provided", ), - ( - {"entity_id": "todo.entity1", "uid": "item-1", "pos": "-2"}, - "invalid_format", - "value must be at least 0", - ), ], ) async def test_move_todo_item_service_invalid_input( @@ -722,7 +717,7 @@ async def test_move_item_unsupported( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() From 10e6a26717e5668bfa6c5ae6bcd7cf8da656db9f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 05:22:38 +0200 Subject: [PATCH 007/643] Fix fan device actions (#102797) --- homeassistant/components/fan/device_action.py | 14 ++++++++++++-- tests/components/fan/test_device_action.py | 3 +++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py index 55bd862349b..fc7f1ddce1f 100644 --- a/homeassistant/components/fan/device_action.py +++ b/homeassistant/components/fan/device_action.py @@ -3,14 +3,24 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_validate_entity_schema, + toggle_entity, +) from homeassistant.const import CONF_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) +_ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) + + +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) async def async_get_actions( diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index 3b179bc158c..b8756d9ace5 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -171,6 +171,7 @@ async def test_action( hass.bus.async_fire("test_event_turn_off") await hass.async_block_till_done() assert len(turn_off_calls) == 1 + assert turn_off_calls[0].data["entity_id"] == entry.entity_id assert len(turn_on_calls) == 0 assert len(toggle_calls) == 0 @@ -178,6 +179,7 @@ async def test_action( await hass.async_block_till_done() assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 + assert turn_on_calls[0].data["entity_id"] == entry.entity_id assert len(toggle_calls) == 0 hass.bus.async_fire("test_event_toggle") @@ -185,6 +187,7 @@ async def test_action( assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 assert len(toggle_calls) == 1 + assert toggle_calls[0].data["entity_id"] == entry.entity_id async def test_action_legacy( From 244fccdae621bf3c12cd48542ace9474a03b02ff Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 25 Oct 2023 17:57:47 -0400 Subject: [PATCH 008/643] Move coordinator first refresh in Blink (#102805) Move coordinator first refresh --- homeassistant/components/blink/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 89438c9c7c1..c6413dd4372 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -86,8 +86,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: blink.auth = Auth(auth_data, no_prompt=True, session=session) blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) coordinator = BlinkUpdateCoordinator(hass, blink) - await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator try: await blink.start() @@ -101,6 +99,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not blink.available: raise ConfigEntryNotReady + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) From 701a5d775826a8683d05e57b7ace71f0c0a724a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Oct 2023 15:55:28 -0500 Subject: [PATCH 009/643] Bump HAP-python 4.9.1 (#102811) --- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 4f6cc24edc8..17d1237e579 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.9.0", + "HAP-python==4.9.1", "fnv-hash-fast==0.5.0", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/requirements_all.txt b/requirements_all.txt index aa93dd46f26..ab9941778cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -26,7 +26,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.9.0 +HAP-python==4.9.1 # homeassistant.components.tasmota HATasmota==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc8a40bc325..f40240690eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.9.0 +HAP-python==4.9.1 # homeassistant.components.tasmota HATasmota==0.7.3 From f2cef7245ac36fdd257f73a6f8268bfd058288d6 Mon Sep 17 00:00:00 2001 From: William Scanlon <6432770+w1ll1am23@users.noreply.github.com> Date: Wed, 25 Oct 2023 23:25:44 -0400 Subject: [PATCH 010/643] Bump pyeconet to 0.1.22 to handle breaking API change (#102820) --- homeassistant/components/econet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 3472ca231e9..26b04929a45 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.20"] + "requirements": ["pyeconet==0.1.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab9941778cc..5986c17e07b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1684,7 +1684,7 @@ pyebox==1.1.4 pyecoforest==0.3.0 # homeassistant.components.econet -pyeconet==0.1.20 +pyeconet==0.1.22 # homeassistant.components.edimax pyedimax==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f40240690eb..bf76fe945ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1269,7 +1269,7 @@ pydroid-ipcam==2.0.0 pyecoforest==0.3.0 # homeassistant.components.econet -pyeconet==0.1.20 +pyeconet==0.1.22 # homeassistant.components.efergy pyefergy==22.1.1 From 767b7ba4d6e494fd3cef8ea21d9b5900873e70fa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 01:08:31 +0200 Subject: [PATCH 011/643] Correct logic for picking bluetooth local name (#102823) * Correct logic for picking bluetooth local name * make test more robust --------- Co-authored-by: J. Nick Koston --- .../components/bluetooth/base_scanner.py | 2 +- .../components/bluetooth/test_base_scanner.py | 31 +++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 240610e4868..8eacd3e291a 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -330,7 +330,7 @@ class BaseHaRemoteScanner(BaseHaScanner): prev_manufacturer_data = prev_advertisement.manufacturer_data prev_name = prev_device.name - if local_name and prev_name and len(prev_name) > len(local_name): + if prev_name and (not local_name or len(prev_name) > len(local_name)): local_name = prev_name if service_uuids and service_uuids != prev_service_uuids: diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index fc870f2bfe3..31d90a6e93d 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -42,7 +42,10 @@ from . import ( from tests.common import async_fire_time_changed, load_fixture -async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.parametrize("name_2", [None, "w"]) +async def test_remote_scanner( + hass: HomeAssistant, enable_bluetooth: None, name_2: str | None +) -> None: """Test the remote scanner base class merges advertisement_data.""" manager = _get_manager() @@ -61,12 +64,25 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No ) switchbot_device_2 = generate_ble_device( "44:44:33:11:23:45", - "w", + name_2, {}, rssi=-100, ) switchbot_device_adv_2 = generate_advertisement_data( - local_name="wohand", + local_name=name_2, + service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], + service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01", 2: b"\x02"}, + rssi=-100, + ) + switchbot_device_3 = generate_ble_device( + "44:44:33:11:23:45", + "wohandlonger", + {}, + rssi=-100, + ) + switchbot_device_adv_3 = generate_advertisement_data( + local_name="wohandlonger", service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01", 2: b"\x02"}, @@ -125,6 +141,15 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No "00000001-0000-1000-8000-00805f9b34fb", } + # The longer name should be used + scanner.inject_advertisement(switchbot_device_3, switchbot_device_adv_3) + assert discovered_device.name == switchbot_device_3.name + + # Inject the shorter name / None again to make + # sure we always keep the longer name + scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2) + assert discovered_device.name == switchbot_device_3.name + cancel() unsetup() From 0d7fb5b0268827107c5674d4718a3b55b26128a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 05:20:50 +0200 Subject: [PATCH 012/643] Use real devices in automation blueprint tests (#102824) --- tests/components/automation/test_blueprint.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py index ad35a2cfbdd..2976886881d 100644 --- a/tests/components/automation/test_blueprint.py +++ b/tests/components/automation/test_blueprint.py @@ -7,13 +7,15 @@ from unittest.mock import patch import pytest +from homeassistant import config_entries from homeassistant.components import automation from homeassistant.components.blueprint import models from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, yaml -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service BUILTIN_BLUEPRINT_FOLDER = pathlib.Path(automation.__file__).parent / "blueprints" @@ -40,8 +42,18 @@ def patch_blueprint(blueprint_path: str, data_path): yield -async def test_notify_leaving_zone(hass: HomeAssistant) -> None: +async def test_notify_leaving_zone( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test notifying leaving a zone blueprint.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) def set_person_state(state, extra={}): hass.states.async_set( @@ -68,7 +80,7 @@ async def test_notify_leaving_zone(hass: HomeAssistant) -> None: "input": { "person_entity": "person.test_person", "zone_entity": "zone.school", - "notify_device": "abcdefgh", + "notify_device": device.id, }, } } @@ -89,7 +101,7 @@ async def test_notify_leaving_zone(hass: HomeAssistant) -> None: "alias": "Notify that a person has left the zone", "domain": "mobile_app", "type": "notify", - "device_id": "abcdefgh", + "device_id": device.id, } message_tpl.hass = hass assert message_tpl.async_render(variables) == "Paulus has left School" From 386c5ecc3e1e1f8c685b1c58eacb29c7982f2d91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Oct 2023 22:23:06 -0500 Subject: [PATCH 013/643] Bump bleak-retry-connector to 3.3.0 (#102825) changelog: https://github.com/Bluetooth-Devices/bleak-retry-connector/compare/v3.2.1...v3.3.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 960a86637ae..06e7d34e68d 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.1", - "bleak-retry-connector==3.2.1", + "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.13.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index def5f0c9afa..3aff4601d45 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,7 +8,7 @@ atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.8.0 bcrypt==4.0.1 -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 diff --git a/requirements_all.txt b/requirements_all.txt index 5986c17e07b..befc5e905cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -530,7 +530,7 @@ bimmer-connected==0.14.2 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 # homeassistant.components.bluetooth bleak==0.21.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf76fe945ef..34d7a6a4207 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -451,7 +451,7 @@ bellows==0.36.8 bimmer-connected==0.14.2 # homeassistant.components.bluetooth -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 # homeassistant.components.bluetooth bleak==0.21.1 From a6f88fb1239a1345b1f1fad6367a90e796243a91 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Thu, 26 Oct 2023 02:59:48 -0700 Subject: [PATCH 014/643] Bump screenlogicpy to v0.9.4 (#102836) --- homeassistant/components/screenlogic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index e61ca04374f..69bed1af700 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.3"] + "requirements": ["screenlogicpy==0.9.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index befc5e905cb..63f4f31ac5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2382,7 +2382,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.3 +screenlogicpy==0.9.4 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34d7a6a4207..c32f38b1cf1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1769,7 +1769,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.3 +screenlogicpy==0.9.4 # homeassistant.components.backup securetar==2023.3.0 From 9e140864ebc5fe91c2aa3a0286ef2b895348031b Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Thu, 26 Oct 2023 19:12:18 +0900 Subject: [PATCH 015/643] Address late review of switchbot cloud (#102842) For Martin's review --- homeassistant/components/switchbot_cloud/climate.py | 8 +++++--- homeassistant/components/switchbot_cloud/switch.py | 2 -- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 8ad0e1ad43f..803669c806d 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType from . import SwitchbotCloudData from .const import DOMAIN @@ -44,7 +43,6 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] @@ -55,7 +53,10 @@ async def async_setup_entry( class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): - """Representation of a SwitchBot air conditionner, as it is an IR device, we don't know the actual state.""" + """Representation of a SwitchBot air conditionner. + + As it is an IR device, we don't know the actual state. + """ _attr_assumed_state = True _attr_supported_features = ( @@ -116,3 +117,4 @@ class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): return await self._do_send_command(temperature=temperature) self._attr_target_temperature = temperature + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index c63b1713b8d..4f2cdc22ba9 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -7,7 +7,6 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType from . import SwitchbotCloudData from .const import DOMAIN @@ -19,7 +18,6 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] From 7e4da1d03be029996918854ee2caf5ce2878f272 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Oct 2023 17:31:53 +0200 Subject: [PATCH 016/643] Bump aiowithings to 1.0.2 (#102852) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index a1df31ceecc..d43ae7da50c 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==1.0.1"] + "requirements": ["aiowithings==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 63f4f31ac5e..61d80f9654e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -387,7 +387,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.1 +aiowithings==1.0.2 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c32f38b1cf1..c6dca20ecd3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -362,7 +362,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.1 +aiowithings==1.0.2 # homeassistant.components.yandex_transport aioymaps==1.2.2 From a490b5e2869ea2cfe1411f46c5cf2c383b5ae095 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 27 Oct 2023 12:09:59 +0200 Subject: [PATCH 017/643] Add connections to PassiveBluetoothProcessorEntity (#102854) --- .../components/bluetooth/passive_update_processor.py | 5 ++++- .../bluetooth/test_passive_update_processor.py | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 8138587b9b5..7dd39c14039 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast from homeassistant import config_entries from homeassistant.const import ( + ATTR_CONNECTIONS, ATTR_IDENTIFIERS, ATTR_NAME, CONF_ENTITY_CATEGORY, @@ -16,7 +17,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import async_get_current_platform from homeassistant.helpers.event import async_track_time_interval @@ -644,6 +645,8 @@ class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProce self._attr_unique_id = f"{address}-{key}" if ATTR_NAME not in self._attr_device_info: self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name + if device_id is None: + self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)} self._attr_name = processor.entity_names.get(entity_key) @property diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 9e3f954a0c5..8cc76e01d8c 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -1208,6 +1208,7 @@ async def test_integration_with_entity_without_a_device( assert entity_one.unique_id == "aa:bb:cc:dd:ee:ff-temperature" assert entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "name": "Generic", } assert entity_one.entity_key == PassiveBluetoothEntityKey( @@ -1396,6 +1397,7 @@ async def test_integration_multiple_entity_platforms( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1412,6 +1414,7 @@ async def test_integration_multiple_entity_platforms( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1556,6 +1559,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1572,6 +1576,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1636,6 +1641,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1652,6 +1658,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1730,6 +1737,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1746,6 +1754,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", From 293025ab6cbf3dec5c4ce88c5a38e0bba07a1630 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 26 Oct 2023 17:26:27 +0200 Subject: [PATCH 018/643] Update frontend to 20231026.0 (#102857) --- 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 064777b4921..31f4dc14559 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==20231025.1"] + "requirements": ["home-assistant-frontend==20231026.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3aff4601d45..e99afb6330b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231025.1 +home-assistant-frontend==20231026.0 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 61d80f9654e..3a85a24589a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231025.1 +home-assistant-frontend==20231026.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6dca20ecd3..678a2eefcf7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231025.1 +home-assistant-frontend==20231026.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From cc7a4d01e316ef8a13b94b5a1aef4e5bc38c3c8d Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 27 Oct 2023 11:30:37 +0200 Subject: [PATCH 019/643] Don't return resources in safe mode (#102865) --- .../components/lovelace/websocket.py | 3 +++ tests/components/lovelace/test_resources.py | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index 423ba3117ea..c9b7cb10386 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -60,6 +60,9 @@ async def websocket_lovelace_resources( """Send Lovelace UI resources over WebSocket configuration.""" resources = hass.data[DOMAIN]["resources"] + if hass.config.safe_mode: + connection.send_result(msg["id"], []) + if not resources.loaded: await resources.async_load() resources.loaded = True diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index 1e2a121d6fb..f7830f03ed6 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -185,3 +185,26 @@ async def test_storage_resources_import_invalid( "resources" in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"] ) + + +async def test_storage_resources_safe_mode( + hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] +) -> None: + """Test defining resources in storage config.""" + + resource_config = [{**item, "id": uuid.uuid4().hex} for item in RESOURCE_EXAMPLES] + hass_storage[resources.RESOURCE_STORAGE_KEY] = { + "key": resources.RESOURCE_STORAGE_KEY, + "version": 1, + "data": {"items": resource_config}, + } + assert await async_setup_component(hass, "lovelace", {}) + + client = await hass_ws_client(hass) + hass.config.safe_mode = True + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [] From 0573981d6ff7cdada85d5b41f93b3d30c567f07d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 27 Oct 2023 10:51:45 +0200 Subject: [PATCH 020/643] Fix mqtt schema import not available for mqtt_room (#102866) --- homeassistant/components/mqtt/__init__.py | 1 + homeassistant/components/mqtt_room/sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 1f8e5bbf2e7..ac229cb677f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -47,6 +47,7 @@ from .client import ( # noqa: F401 publish, subscribe, ) +from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA # noqa: F401 from .config_integration import CONFIG_SCHEMA_BASE from .const import ( # noqa: F401 ATTR_PAYLOAD, diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 4eb3a3f5171..cb0e840604e 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -47,7 +47,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } -).extend(mqtt.config.MQTT_RO_SCHEMA.schema) +).extend(mqtt.MQTT_RO_SCHEMA.schema) @lru_cache(maxsize=256) From 62fc9dfd6c27f6c2e1fa36b231ea988f8a6e38dc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Oct 2023 12:25:27 +0200 Subject: [PATCH 021/643] Allow missing components in safe mode (#102888) --- homeassistant/helpers/check_config.py | 2 +- tests/helpers/test_check_config.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 3218c1e839b..4aa4e72b0bb 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -127,7 +127,7 @@ async def async_check_ha_config_file( # noqa: C901 try: integration = await async_get_integration_with_requirements(hass, domain) except loader.IntegrationNotFound as ex: - if not hass.config.recovery_mode: + if not hass.config.recovery_mode and not hass.config.safe_mode: result.add_error(f"Integration error: {domain} - {ex}") continue except RequirementsNotFound as ex: diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 6af03136760..a3fd02686ac 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -125,6 +125,19 @@ async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: assert not res.errors +async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: + """Test no errors if component not found in safe mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + assert not res.errors + + async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: """Test errors if component or platform not found.""" # Make sure they don't exist From b5c75a2f2f429b7c91207f4677cf32b114fd49df Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Oct 2023 13:26:26 +0200 Subject: [PATCH 022/643] Allow missing components in safe mode (#102891) --- homeassistant/helpers/check_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 4aa4e72b0bb..a5e68cb877d 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -216,7 +216,7 @@ async def async_check_ha_config_file( # noqa: C901 ) platform = p_integration.get_platform(domain) except loader.IntegrationNotFound as ex: - if not hass.config.recovery_mode: + if not hass.config.recovery_mode and not hass.config.safe_mode: result.add_error(f"Platform error {domain}.{p_name} - {ex}") continue except ( From 5dca3844ef1b6e422330643ac627cf3a6545d77e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 27 Oct 2023 13:55:22 +0200 Subject: [PATCH 023/643] Add redirect from shopping list to todo (#102894) --- homeassistant/components/frontend/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8201cbc5b7a..2ec991750f0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -388,6 +388,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Can be removed in 2023 hass.http.register_redirect("/config/server_control", "/developer-tools/yaml") + # Shopping list panel was replaced by todo panel in 2023.11 + hass.http.register_redirect("/shopping-list", "/todo") + hass.http.app.router.register_resource(IndexView(repo_path, hass)) async_register_built_in_panel(hass, "profile") From 7fe1ac901fb0b3deecbc35409020f9d718114767 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Oct 2023 13:28:16 +0200 Subject: [PATCH 024/643] Some textual fixes for todo (#102895) --- homeassistant/components/todo/manifest.json | 2 +- homeassistant/components/todo/services.yaml | 4 +-- homeassistant/components/todo/strings.json | 34 ++++++++++----------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/todo/manifest.json b/homeassistant/components/todo/manifest.json index 2edf3309e32..8efc93ad4e7 100644 --- a/homeassistant/components/todo/manifest.json +++ b/homeassistant/components/todo/manifest.json @@ -1,6 +1,6 @@ { "domain": "todo", - "name": "To-do", + "name": "To-do list", "codeowners": ["@home-assistant/core"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/todo", diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index cf5f3da2b3a..c31a7e88808 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -7,7 +7,7 @@ create_item: fields: summary: required: true - example: "Submit Income Tax Return" + example: "Submit income tax return" selector: text: status: @@ -29,7 +29,7 @@ update_item: selector: text: summary: - example: "Submit Income Tax Return" + example: "Submit income tax return" selector: text: status: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 4a5a33e94e5..623c46375f0 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -1,5 +1,5 @@ { - "title": "To-do List", + "title": "To-do list", "entity_component": { "_": { "name": "[%key:component::todo::title%]" @@ -7,48 +7,48 @@ }, "services": { "create_item": { - "name": "Create To-do List Item", - "description": "Add a new To-do List Item.", + "name": "Create to-do list item", + "description": "Add a new to-do list item.", "fields": { "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." }, "status": { "name": "Status", - "description": "A status or confirmation of the To-do item." + "description": "A status or confirmation of the to-do item." } } }, "update_item": { - "name": "Update To-do List Item", - "description": "Update an existing To-do List Item based on either its Unique Id or Summary.", + "name": "Update to-do list item", + "description": "Update an existing to-do list item based on either its unique ID or summary.", "fields": { "uid": { - "name": "To-do Item Unique Id", - "description": "Unique Identifier for the To-do List Item." + "name": "To-do item unique ID", + "description": "Unique identifier for the to-do list item." }, "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." }, "status": { "name": "Status", - "description": "A status or confirmation of the To-do item." + "description": "A status or confirmation of the to-do item." } } }, "delete_item": { - "name": "Delete a To-do List Item", - "description": "Delete an existing To-do List Item either by its Unique Id or Summary.", + "name": "Delete a to-do list item", + "description": "Delete an existing to-do list item either by its unique ID or summary.", "fields": { "uid": { - "name": "To-do Item Unique Ids", - "description": "Unique Identifiers for the To-do List Items." + "name": "To-do item unique IDs", + "description": "Unique identifiers for the to-do list items." }, "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." } } } @@ -56,7 +56,7 @@ "selector": { "status": { "options": { - "needs_action": "Needs Action", + "needs_action": "Not completed", "completed": "Completed" } } From 867aaf10ee63b5efe3bc7260724440e62e3c7007 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Oct 2023 14:02:42 +0200 Subject: [PATCH 025/643] Bumped version to 2023.11.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5b8e9f43b21..39f957ef77c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 8068b7e55b7..1303d1cd7fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b0" +version = "2023.11.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 2eb2a651978f3b74f72b60960d3942638391877d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 026/643] Use new API for Vasttrafik (#102570) --- .../components/vasttrafik/manifest.json | 2 +- homeassistant/components/vasttrafik/sensor.py | 44 +++++++++++++------ requirements_all.txt | 2 +- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/vasttrafik/manifest.json b/homeassistant/components/vasttrafik/manifest.json index aa1907a8a23..336d06e182c 100644 --- a/homeassistant/components/vasttrafik/manifest.json +++ b/homeassistant/components/vasttrafik/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/vasttrafik", "iot_class": "cloud_polling", "loggers": ["vasttrafik"], - "requirements": ["vtjp==0.1.14"] + "requirements": ["vtjp==0.2.1"] } diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 711f66ea033..6a083232079 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -1,7 +1,7 @@ """Support for Västtrafik public transport.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging import vasttrafik @@ -22,6 +22,9 @@ ATTR_ACCESSIBILITY = "accessibility" ATTR_DIRECTION = "direction" ATTR_LINE = "line" ATTR_TRACK = "track" +ATTR_FROM = "from" +ATTR_TO = "to" +ATTR_DELAY = "delay" CONF_DEPARTURES = "departures" CONF_FROM = "from" @@ -32,7 +35,6 @@ CONF_SECRET = "secret" DEFAULT_DELAY = 0 - MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -101,7 +103,7 @@ class VasttrafikDepartureSensor(SensorEntity): if location.isdecimal(): station_info = {"station_name": location, "station_id": location} else: - station_id = self._planner.location_name(location)[0]["id"] + station_id = self._planner.location_name(location)[0]["gid"] station_info = {"station_name": location, "station_id": station_id} return station_info @@ -143,20 +145,36 @@ class VasttrafikDepartureSensor(SensorEntity): self._attributes = {} else: for departure in self._departureboard: - line = departure.get("sname") - if "cancelled" in departure: + service_journey = departure.get("serviceJourney", {}) + line = service_journey.get("line", {}) + + if departure.get("isCancelled"): continue - if not self._lines or line in self._lines: - if "rtTime" in departure: - self._state = departure["rtTime"] + if not self._lines or line.get("shortName") in self._lines: + if "estimatedOtherwisePlannedTime" in departure: + try: + self._state = datetime.fromisoformat( + departure["estimatedOtherwisePlannedTime"] + ).strftime("%H:%M") + except ValueError: + self._state = departure["estimatedOtherwisePlannedTime"] else: - self._state = departure["time"] + self._state = None + + stop_point = departure.get("stopPoint", {}) params = { - ATTR_ACCESSIBILITY: departure.get("accessibility"), - ATTR_DIRECTION: departure.get("direction"), - ATTR_LINE: departure.get("sname"), - ATTR_TRACK: departure.get("track"), + ATTR_ACCESSIBILITY: "wheelChair" + if line.get("isWheelchairAccessible") + else None, + ATTR_DIRECTION: service_journey.get("direction"), + ATTR_LINE: line.get("shortName"), + ATTR_TRACK: stop_point.get("platform"), + ATTR_FROM: stop_point.get("name"), + ATTR_TO: self._heading["station_name"] + if self._heading + else "ANY", + ATTR_DELAY: self._delay.seconds // 60 % 60, } self._attributes = {k: v for k, v in params.items() if v} diff --git a/requirements_all.txt b/requirements_all.txt index 3a85a24589a..173a7f8894f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2682,7 +2682,7 @@ volvooncall==0.10.3 vsure==2.6.6 # homeassistant.components.vasttrafik -vtjp==0.1.14 +vtjp==0.2.1 # homeassistant.components.vulcan vulcan-api==2.3.0 From a60656bf29bb1db7f71ebfac50c6e94e5e2cf1d2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 027/643] Improve fitbit oauth import robustness (#102833) * Improve fitbit oauth import robustness * Improve sensor tests and remove unnecessary client check * Fix oauth client id/secret config key checks * Add executor for sync call --- homeassistant/components/fitbit/sensor.py | 71 +++++++++++++-------- tests/components/fitbit/test_config_flow.py | 59 +++++++++++++++++ tests/components/fitbit/test_sensor.py | 15 ++++- 3 files changed, 118 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 45b8ea21b0e..4885c9fa16d 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -8,6 +8,8 @@ import logging import os from typing import Any, Final, cast +from fitbit import Fitbit +from oauthlib.oauth2.rfc6749.errors import OAuth2Error import voluptuous as vol from homeassistant.components.application_credentials import ( @@ -567,34 +569,51 @@ async def async_setup_platform( if config_file is not None: _LOGGER.debug("Importing existing fitbit.conf application credentials") - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] - ), + + # Refresh the token before importing to ensure it is working and not + # expired on first initialization. + authd_client = Fitbit( + config_file[CONF_CLIENT_ID], + config_file[CONF_CLIENT_SECRET], + access_token=config_file[ATTR_ACCESS_TOKEN], + refresh_token=config_file[ATTR_REFRESH_TOKEN], + expires_at=config_file[ATTR_LAST_SAVED_AT], + refresh_cb=lambda x: None, ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - "auth_implementation": DOMAIN, - CONF_TOKEN: { - ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], - ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], - "expires_at": config_file[ATTR_LAST_SAVED_AT], - }, - CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], - CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], - CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], - }, - ) - translation_key = "deprecated_yaml_import" - if ( - result.get("type") == FlowResultType.ABORT - and result.get("reason") == "cannot_connect" - ): + try: + await hass.async_add_executor_job(authd_client.client.refresh_token) + except OAuth2Error as err: + _LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err) translation_key = "deprecated_yaml_import_issue_cannot_connect" + else: + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] + ), + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + "auth_implementation": DOMAIN, + CONF_TOKEN: { + ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], + ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], + "expires_at": config_file[ATTR_LAST_SAVED_AT], + }, + CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], + CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], + CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], + }, + ) + translation_key = "deprecated_yaml_import" + if ( + result.get("type") == FlowResultType.ABORT + and result.get("reason") == "cannot_connect" + ): + translation_key = "deprecated_yaml_import_issue_cannot_connect" else: translation_key = "deprecated_yaml_no_import" diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index e6ab39aff59..152439ec19a 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -209,9 +209,17 @@ async def test_import_fitbit_config( fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, + requests_mock: Mocker, ) -> None: """Test that platform configuration is imported successfully.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: @@ -256,6 +264,12 @@ async def test_import_fitbit_config_failure_cannot_connect( ) -> None: """Test platform configuration fails to import successfully.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) requests_mock.register_uri( "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR ) @@ -273,6 +287,43 @@ async def test_import_fitbit_config_failure_cannot_connect( assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" +@pytest.mark.parametrize( + "status_code", + [ + (HTTPStatus.UNAUTHORIZED), + (HTTPStatus.INTERNAL_SERVER_ERROR), + ], +) +async def test_import_fitbit_config_cannot_refresh( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, + requests_mock: Mocker, + status_code: HTTPStatus, +) -> None: + """Test platform configuration import fails when refreshing the token.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=status_code, + json="", + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + assert len(mock_setup.mock_calls) == 0 + + # Verify an issue is raised that we were unable to import configuration + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + + async def test_import_fitbit_config_already_exists( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -281,9 +332,17 @@ async def test_import_fitbit_config_already_exists( fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, + requests_mock: Mocker, ) -> None: """Test that platform configuration is not imported if it already exists.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + # Verify existing config entry entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index b54f154d406..5421a652125 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -9,7 +9,7 @@ import pytest from requests_mock.mocker import Mocker from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fitbit.const import DOMAIN +from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_TOKEN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,6 +23,7 @@ from homeassistant.util.unit_system import ( from .conftest import ( DEVICES_API_URL, PROFILE_USER_ID, + SERVER_ACCESS_TOKEN, TIMESERIES_API_URL_FORMAT, timeseries_response, ) @@ -55,6 +56,18 @@ def platforms() -> list[str]: return [Platform.SENSOR] +@pytest.fixture(autouse=True) +def mock_token_refresh(requests_mock: Mocker) -> None: + """Test that platform configuration is imported successfully.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + + @pytest.mark.parametrize( ( "monitored_resources", From 4617c16a961c2bb47eeaf62d518c11408cda1ac5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 028/643] Update aioairzone-cloud to v0.3.1 (#102899) --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone_cloud/util.py | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index a3c0f5e7dc0..eb959342122 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.0"] + "requirements": ["aioairzone-cloud==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 173a7f8894f..3ece034b571 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.0 +aioairzone-cloud==0.3.1 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 678a2eefcf7..c89d160b4ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.0 +aioairzone-cloud==0.3.1 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 412f0df1337..76349d06481 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -101,6 +101,7 @@ GET_INSTALLATION_MOCK = { API_WS_ID: WS_ID, }, { + API_CONFIG: {}, API_DEVICE_ID: "zone1", API_NAME: "Salon", API_TYPE: API_AZ_ZONE, @@ -111,6 +112,7 @@ GET_INSTALLATION_MOCK = { API_WS_ID: WS_ID, }, { + API_CONFIG: {}, API_DEVICE_ID: "zone2", API_NAME: "Dormitorio", API_TYPE: API_AZ_ZONE, From 3d321c5ca7ceea4a2f81fcfbb1f80ee1c1471302 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 029/643] Update frontend to 20231027.0 (#102913) --- 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 31f4dc14559..a47ef38264e 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==20231026.0"] + "requirements": ["home-assistant-frontend==20231027.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e99afb6330b..5d68cead747 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231026.0 +home-assistant-frontend==20231027.0 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3ece034b571..2b78d878adf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231026.0 +home-assistant-frontend==20231027.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c89d160b4ce..ae7c3ea23cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231026.0 +home-assistant-frontend==20231027.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From 29c99f419f70faa36eca0116c72afff215ac4485 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 030/643] Bump velbusaio to 2023.10.2 (#102919) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 229ee8458c6..3c773e39e33 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2023.10.1"], + "requirements": ["velbus-aio==2023.10.2"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 2b78d878adf..a3d43bb2a9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2661,7 +2661,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.1 +velbus-aio==2023.10.2 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae7c3ea23cd..9a27453bf95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1979,7 +1979,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.1 +velbus-aio==2023.10.2 # homeassistant.components.venstar venstarcolortouch==0.19 From bee63ca654164150967fa392a635ae454ab10e3a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 031/643] Hide mac address from HomeWizard Energy config entry/discovery titles (#102931) --- homeassistant/components/homewizard/config_flow.py | 14 +++++++++----- tests/components/homewizard/test_config_flow.py | 14 +++++++------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 82c808a0f13..b24b49da965 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -62,7 +62,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry( - title=f"{device_info.product_name} ({device_info.serial})", + title=f"{device_info.product_name}", data=user_input, ) @@ -121,14 +121,18 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors = {"base": ex.error_code} else: return self.async_create_entry( - title=f"{self.discovery.product_name} ({self.discovery.serial})", + title=self.discovery.product_name, data={CONF_IP_ADDRESS: self.discovery.ip}, ) self._set_confirm_only() - self.context["title_placeholders"] = { - "name": f"{self.discovery.product_name} ({self.discovery.serial})" - } + + # We won't be adding mac/serial to the title for devices + # that users generally don't have multiple of. + name = self.discovery.product_name + if self.discovery.product_type not in ["HWE-P1", "HWE-WTR"]: + name = f"{name} ({self.discovery.serial})" + self.context["title_placeholders"] = {"name": name} return self.async_show_form( step_id="discovery_confirm", diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 7c6fb0bdb0d..770496b5612 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -43,7 +43,7 @@ async def test_manual_flow_works( ) assert result["type"] == "create_entry" - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "2.2.2.2" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -68,8 +68,8 @@ async def test_discovery_flow_works( properties={ "api_enabled": "1", "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", + "product_name": "Energy Socket", + "product_type": "HWE-SKT", "serial": "aabbccddeeff", }, ) @@ -109,11 +109,11 @@ async def test_discovery_flow_works( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "Energy Socket" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] - assert result["result"].unique_id == "HWE-P1_aabbccddeeff" + assert result["result"].unique_id == "HWE-SKT_aabbccddeeff" async def test_discovery_flow_during_onboarding( @@ -149,7 +149,7 @@ async def test_discovery_flow_during_onboarding( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] @@ -214,7 +214,7 @@ async def test_discovery_flow_during_onboarding_disabled_api( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] From 1c3de76b045e23d42518eb1e7e9bbaf00580fb55 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 032/643] Move HomeWizard Energy identify button to config entity category (#102932) --- homeassistant/components/homewizard/button.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 96fe1b157f8..19ffb1d6042 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -24,7 +24,7 @@ async def async_setup_entry( class HomeWizardIdentifyButton(HomeWizardEntity, ButtonEntity): """Representation of a identify button.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_category = EntityCategory.CONFIG _attr_device_class = ButtonDeviceClass.IDENTIFY def __init__( From 974c34e2b63c9d2380f9b11799fdb926afce3990 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 033/643] Small base entity cleanup for HomeWizard Energy entities (#102933) --- homeassistant/components/homewizard/entity.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 51dbe9fcad3..61bf20dbbc4 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -18,17 +18,13 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): """Initialize the HomeWizard entity.""" super().__init__(coordinator=coordinator) self._attr_device_info = DeviceInfo( - name=coordinator.entry.title, manufacturer="HomeWizard", sw_version=coordinator.data.device.firmware_version, model=coordinator.data.device.product_type, ) - if coordinator.data.device.serial is not None: + if (serial_number := coordinator.data.device.serial) is not None: self._attr_device_info[ATTR_CONNECTIONS] = { - (CONNECTION_NETWORK_MAC, coordinator.data.device.serial) - } - - self._attr_device_info[ATTR_IDENTIFIERS] = { - (DOMAIN, coordinator.data.device.serial) + (CONNECTION_NETWORK_MAC, serial_number) } + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)} From f9f010643a630f73f44f4cc02c8b4f4e3cc1c853 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 034/643] Handle/extend number entity availability property in HomeWizard Energy (#102934) --- homeassistant/components/homewizard/number.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index d51d180edb1..07f6bb9b55f 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -47,13 +47,17 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) await self.coordinator.async_refresh() + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.state is not None + @property def native_value(self) -> float | None: """Return the current value.""" if ( - self.coordinator.data.state is None - or self.coordinator.data.state.brightness is None + not self.coordinator.data.state + or (brightness := self.coordinator.data.state.brightness) is None ): return None - brightness: float = self.coordinator.data.state.brightness return round(brightness * (100 / 255)) From 07e4e1379ad14e295a92bc1b4839a35d32c64b48 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 035/643] Improve diagnostic handling in HomeWizard Energy (#102935) --- .../components/homewizard/diagnostics.py | 33 +++++---- .../snapshots/test_diagnostics.ambr | 71 +++++++++++++++++++ .../components/homewizard/test_diagnostics.py | 70 ++---------------- 3 files changed, 97 insertions(+), 77 deletions(-) create mode 100644 tests/components/homewizard/snapshots/test_diagnostics.ambr diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index a8f89b67ce9..b8103f7a4cb 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -28,18 +28,23 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - meter_data = { - "device": asdict(coordinator.data.device), - "data": asdict(coordinator.data.data), - "state": asdict(coordinator.data.state) - if coordinator.data.state is not None - else None, - "system": asdict(coordinator.data.system) - if coordinator.data.system is not None - else None, - } + state: dict[str, Any] | None = None + if coordinator.data.state: + state = asdict(coordinator.data.state) - return { - "entry": async_redact_data(entry.data, TO_REDACT), - "data": async_redact_data(meter_data, TO_REDACT), - } + system: dict[str, Any] | None = None + if coordinator.data.system: + system = asdict(coordinator.data.system) + + return async_redact_data( + { + "entry": async_redact_data(entry.data, TO_REDACT), + "data": { + "device": asdict(coordinator.data.device), + "data": asdict(coordinator.data.data), + "state": state, + "system": system, + }, + }, + TO_REDACT, + ) diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..5e1025a8d31 --- /dev/null +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -0,0 +1,71 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': -4, + 'active_current_l2_a': 2, + 'active_current_l3_a': 0, + 'active_frequency_hz': 50, + 'active_liter_lpm': 12.345, + 'active_power_average_w': 123.0, + 'active_power_l1_w': -123, + 'active_power_l2_w': 456, + 'active_power_l3_w': 123.456, + 'active_power_w': -123, + 'active_tariff': 2, + 'active_voltage_l1_v': 230.111, + 'active_voltage_l2_v': 230.222, + 'active_voltage_l3_v': 230.333, + 'any_power_fail_count': 4, + 'external_devices': None, + 'gas_timestamp': '2021-03-14T11:22:33', + 'gas_unique_id': '**REDACTED**', + 'long_power_fail_count': 5, + 'meter_model': 'ISKRA 2M550T-101', + 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', + 'monthly_power_peak_w': 1111.0, + 'smr_version': 50, + 'total_gas_m3': 1122.333, + 'total_liter_m3': 1234.567, + 'total_power_export_kwh': 13086.777, + 'total_power_export_t1_kwh': 4321.333, + 'total_power_export_t2_kwh': 8765.444, + 'total_power_export_t3_kwh': None, + 'total_power_export_t4_kwh': None, + 'total_power_import_kwh': 13779.338, + 'total_power_import_t1_kwh': 10830.511, + 'total_power_import_t2_kwh': 2948.827, + 'total_power_import_t3_kwh': None, + 'total_power_import_t4_kwh': None, + 'unique_meter_id': '**REDACTED**', + 'voltage_sag_l1_count': 1, + 'voltage_sag_l2_count': 2, + 'voltage_sag_l3_count': 3, + 'voltage_swell_l1_count': 4, + 'voltage_swell_l2_count': 5, + 'voltage_swell_l3_count': 6, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 100, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '2.11', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'state': dict({ + 'brightness': 255, + 'power_on': True, + 'switch_lock': False, + }), + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index 64e8b0c6dfd..9e9797439b3 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -1,6 +1,7 @@ """Tests for diagnostics data.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,67 +13,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "entry": {"ip_address": REDACTED}, - "data": { - "device": { - "product_name": "P1 Meter", - "product_type": "HWE-SKT", - "serial": REDACTED, - "api_version": "v1", - "firmware_version": "2.11", - }, - "data": { - "wifi_ssid": REDACTED, - "wifi_strength": 100, - "smr_version": 50, - "meter_model": "ISKRA 2M550T-101", - "unique_meter_id": REDACTED, - "active_tariff": 2, - "total_power_import_kwh": 13779.338, - "total_power_import_t1_kwh": 10830.511, - "total_power_import_t2_kwh": 2948.827, - "total_power_import_t3_kwh": None, - "total_power_import_t4_kwh": None, - "total_power_export_kwh": 13086.777, - "total_power_export_t1_kwh": 4321.333, - "total_power_export_t2_kwh": 8765.444, - "total_power_export_t3_kwh": None, - "total_power_export_t4_kwh": None, - "active_power_w": -123, - "active_power_l1_w": -123, - "active_power_l2_w": 456, - "active_power_l3_w": 123.456, - "active_voltage_l1_v": 230.111, - "active_voltage_l2_v": 230.222, - "active_voltage_l3_v": 230.333, - "active_current_l1_a": -4, - "active_current_l2_a": 2, - "active_current_l3_a": 0, - "active_frequency_hz": 50, - "voltage_sag_l1_count": 1, - "voltage_sag_l2_count": 2, - "voltage_sag_l3_count": 3, - "voltage_swell_l1_count": 4, - "voltage_swell_l2_count": 5, - "voltage_swell_l3_count": 6, - "any_power_fail_count": 4, - "long_power_fail_count": 5, - "active_power_average_w": 123.0, - "monthly_power_peak_w": 1111.0, - "monthly_power_peak_timestamp": "2023-01-01T08:00:10", - "total_gas_m3": 1122.333, - "gas_timestamp": "2021-03-14T11:22:33", - "gas_unique_id": REDACTED, - "active_liter_lpm": 12.345, - "total_liter_m3": 1234.567, - "external_devices": None, - }, - "state": {"power_on": True, "switch_lock": False, "brightness": 255}, - "system": {"cloud_enabled": True}, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From bcddf52364d08d359ba033f986c1468c6871b920 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:03 -0400 Subject: [PATCH 036/643] Update xknxproject to 3.4.0 (#102946) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index b5c98c7203a..a233ca38705 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==2.11.2", - "xknxproject==3.3.0", + "xknxproject==3.4.0", "knx-frontend==2023.6.23.191712" ] } diff --git a/requirements_all.txt b/requirements_all.txt index a3d43bb2a9b..b69dbf4c258 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2740,7 +2740,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.3.0 +xknxproject==3.4.0 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a27453bf95..5ec473c9ef1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2043,7 +2043,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.3.0 +xknxproject==3.4.0 # homeassistant.components.bluesound # homeassistant.components.fritz From 85d999b020de586ad07eb8a0c94e9b78ac099512 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:03 -0400 Subject: [PATCH 037/643] Add gas device class to dsmr_reader sensor (#102953) DSMR reader integration - can't configure gas meter in energy dashboard posible due to missing device_class Fixes #102367 --- homeassistant/components/dsmr_reader/definitions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 33bba375fd3..d89e30311e9 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -141,6 +141,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( translation_key="gas_meter_usage", entity_registry_enabled_default=False, icon="mdi:fire", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -209,6 +210,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", translation_key="current_gas_usage", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.MEASUREMENT, ), @@ -283,6 +285,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/gas", translation_key="daily_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( @@ -460,6 +463,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/current-month/gas", translation_key="current_month_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( @@ -538,6 +542,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/current-year/gas", translation_key="current_year_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( From 9c9f1ea685535606be244ea2313169146e2fbcce Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:03 -0400 Subject: [PATCH 038/643] Fix error message strings for Todoist configuration flow (#102968) * Fix error message strings for Todoist configuration flow * Update error code in test --- homeassistant/components/todoist/config_flow.py | 2 +- homeassistant/components/todoist/strings.json | 6 ++++-- tests/components/todoist/test_config_flow.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index 6098df40ea0..b8c79210dfb 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -44,7 +44,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await api.get_tasks() except HTTPError as err: if err.response.status_code == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_access_token" + errors["base"] = "invalid_api_key" else: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 68c2305d073..442114eb118 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -9,10 +9,12 @@ } }, "error": { - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/todoist/test_config_flow.py b/tests/components/todoist/test_config_flow.py index 4175902da31..141f12269de 100644 --- a/tests/components/todoist/test_config_flow.py +++ b/tests/components/todoist/test_config_flow.py @@ -69,7 +69,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("errors") == {"base": "invalid_access_token"} + assert result2.get("errors") == {"base": "invalid_api_key"} @pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) From 9c8a4bb4eb3aa9cc4058e039b56ce00fdab50f2f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:03 -0400 Subject: [PATCH 039/643] Fix proximity zone handling (#102971) * fix proximity zone * fix test --- homeassistant/components/proximity/__init__.py | 12 ++++++------ tests/components/proximity/test_init.py | 8 ++++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index a4520435161..07b5f931f79 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -70,22 +70,22 @@ def async_setup_proximity_component( ignored_zones: list[str] = config[CONF_IGNORED_ZONES] proximity_devices: list[str] = config[CONF_DEVICES] tolerance: int = config[CONF_TOLERANCE] - proximity_zone = name + proximity_zone = config[CONF_ZONE] unit_of_measurement: str = config.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) - zone_id = f"zone.{config[CONF_ZONE]}" + zone_friendly_name = name proximity = Proximity( hass, - proximity_zone, + zone_friendly_name, DEFAULT_DIST_TO_ZONE, DEFAULT_DIR_OF_TRAVEL, DEFAULT_NEAREST, ignored_zones, proximity_devices, tolerance, - zone_id, + proximity_zone, unit_of_measurement, ) proximity.entity_id = f"{DOMAIN}.{proximity_zone}" @@ -171,7 +171,7 @@ class Proximity(Entity): devices_to_calculate = False devices_in_zone = "" - zone_state = self.hass.states.get(self.proximity_zone) + zone_state = self.hass.states.get(f"zone.{self.proximity_zone}") proximity_latitude = ( zone_state.attributes.get(ATTR_LATITUDE) if zone_state else None ) @@ -189,7 +189,7 @@ class Proximity(Entity): devices_to_calculate = True # Check the location of all devices. - if (device_state.state).lower() == (self.friendly_name).lower(): + if (device_state.state).lower() == (self.proximity_zone).lower(): device_friendly = device_state.name if devices_in_zone != "": devices_in_zone = f"{devices_in_zone}, " diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 34f87b5c261..0ec8765e604 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -13,7 +13,11 @@ async def test_proximities(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "work": {"devices": ["device_tracker.test1"], "tolerance": "1"}, + "work": { + "devices": ["device_tracker.test1"], + "tolerance": "1", + "zone": "work", + }, } } @@ -42,7 +46,7 @@ async def test_proximities_setup(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "work": {"tolerance": "1"}, + "work": {"tolerance": "1", "zone": "work"}, } } From eef318f63cd74150b3e462d4af8814478126049c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:03 -0400 Subject: [PATCH 040/643] Bumped version to 2023.11.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 39f957ef77c..cbc21687110 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 1303d1cd7fe..92c11a19a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b1" +version = "2023.11.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 59d2bce369687246a51bde8d6033ed9ba2334a2e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 30 Oct 2023 10:29:40 +0100 Subject: [PATCH 041/643] Enable dry mode for Tado AC's V3 (#99568) --- homeassistant/components/tado/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 9366a18b6fe..d6ae50c33c1 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -119,7 +119,7 @@ TADO_MODES_TO_HA_CURRENT_HVAC_ACTION = { } # These modes will not allow a temp to be set -TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_DRY, CONST_MODE_FAN] +TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_FAN] # # HVAC_MODE_HEAT_COOL is mapped to CONST_MODE_AUTO # This lets tado decide on a temp From a741bc9951fc25b4b5b9fa6f29bdc35fdef6b6f9 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 30 Oct 2023 10:16:41 -0400 Subject: [PATCH 042/643] Add retry before unavailable to Honeywell (#101702) Co-authored-by: Robert Resch --- homeassistant/components/honeywell/climate.py | 13 ++++++++-- homeassistant/components/honeywell/const.py | 1 + tests/components/honeywell/test_climate.py | 26 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 63d05135d5d..ab23c878c15 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -38,6 +38,7 @@ from .const import ( CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE, DOMAIN, + RETRY, ) ATTR_FAN_ACTION = "fan_action" @@ -155,6 +156,7 @@ class HoneywellUSThermostat(ClimateEntity): self._cool_away_temp = cool_away_temp self._heat_away_temp = heat_away_temp self._away = False + self._retry = 0 self._attr_unique_id = device.deviceid @@ -483,21 +485,28 @@ class HoneywellUSThermostat(ClimateEntity): try: await self._device.refresh() self._attr_available = True + self._retry = 0 + except UnauthorizedError: try: await self._data.client.login() await self._device.refresh() self._attr_available = True + self._retry = 0 except ( SomeComfortError, ClientConnectionError, asyncio.TimeoutError, ): - self._attr_available = False + self._retry += 1 + if self._retry > RETRY: + self._attr_available = False except (ClientConnectionError, asyncio.TimeoutError): - self._attr_available = False + self._retry += 1 + if self._retry > RETRY: + self._attr_available = False except UnexpectedResponse: pass diff --git a/homeassistant/components/honeywell/const.py b/homeassistant/components/honeywell/const.py index d5153a69f65..32846563c44 100644 --- a/homeassistant/components/honeywell/const.py +++ b/homeassistant/components/honeywell/const.py @@ -10,3 +10,4 @@ DEFAULT_HEAT_AWAY_TEMPERATURE = 61 CONF_DEV_ID = "thermostat" CONF_LOC_ID = "location" _LOGGER = logging.getLogger(__name__) +RETRY = 3 diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 7bd76cb8522..53cb70475c9 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -28,7 +28,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.honeywell.climate import SCAN_INTERVAL +from homeassistant.components.honeywell.climate import RETRY, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -1083,6 +1083,17 @@ async def test_async_update_errors( state = hass.states.get(entity_id) assert state.state == "off" + # Due to server instability, only mark entity unavailable after RETRY update attempts + for _ in range(RETRY): + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + async_fire_time_changed( hass, utcnow() + SCAN_INTERVAL, @@ -1126,7 +1137,6 @@ async def test_async_update_errors( state = hass.states.get(entity_id) assert state.state == "off" - # "reload integration" test device.refresh.side_effect = aiosomecomfort.SomeComfortError client.login.side_effect = aiosomecomfort.AuthError async_fire_time_changed( @@ -1139,6 +1149,18 @@ async def test_async_update_errors( assert state.state == "off" device.refresh.side_effect = ClientConnectionError + + # Due to server instability, only mark entity unavailable after RETRY update attempts + for _ in range(RETRY): + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + async_fire_time_changed( hass, utcnow() + SCAN_INTERVAL, From 20409d0124243aefd86990c999c4e2a15333158f Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 30 Oct 2023 10:18:59 -0400 Subject: [PATCH 043/643] Make Hydrawise initialize data immediately (#101936) --- .../components/hydrawise/__init__.py | 26 +++----------- .../components/hydrawise/binary_sensor.py | 11 +++--- homeassistant/components/hydrawise/entity.py | 12 +++++++ homeassistant/components/hydrawise/sensor.py | 12 +++---- homeassistant/components/hydrawise/switch.py | 10 ++---- .../hydrawise/test_binary_sensor.py | 5 --- tests/components/hydrawise/test_init.py | 34 ++++++++----------- tests/components/hydrawise/test_sensor.py | 12 ++----- tests/components/hydrawise/test_switch.py | 9 +---- 9 files changed, 45 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index bc3c62cfb9f..ddff1954eb3 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -2,7 +2,6 @@ from pydrawise import legacy -from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -13,11 +12,10 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL from .coordinator import HydrawiseDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( @@ -53,24 +51,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Hydrawise from a config entry.""" access_token = config_entry.data[CONF_API_KEY] - try: - hydrawise = await hass.async_add_executor_job( - legacy.LegacyHydrawise, access_token - ) - except (ConnectTimeout, HTTPError) as ex: - LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) - raise ConfigEntryNotReady( - f"Unable to connect to Hydrawise cloud service: {ex}" - ) from ex - - hass.data.setdefault(DOMAIN, {})[ - config_entry.entry_id - ] = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) - if not hydrawise.controller_info or not hydrawise.controller_status: - raise ConfigEntryNotReady("Hydrawise data not loaded") - - # NOTE: We don't need to call async_config_entry_first_refresh() because - # data is fetched when the Hydrawiser object is instantiated. + hydrawise = legacy.LegacyHydrawise(access_token, load_on_init=False) + coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 30096a9bf97..1953e413672 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -12,12 +12,12 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -95,13 +95,10 @@ async def async_setup_entry( class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): """A sensor implementation for Hydrawise device.""" - @callback - def _handle_coordinator_update(self) -> None: - """Get the latest data and updates the state.""" - LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) + def _update_attrs(self) -> None: + """Update state attributes.""" if self.entity_description.key == "status": self._attr_is_on = self.coordinator.last_update_success elif self.entity_description.key == "is_watering": relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] self._attr_is_on = relay_data["timestr"] == "Now" - super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index c3f295e1c4d..38fde322673 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -36,3 +37,14 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): name=data["name"], manufacturer=MANUFACTURER, ) + self._update_attrs() + + def _update_attrs(self) -> None: + """Update state attributes.""" + return # pragma: no cover + + @callback + def _handle_coordinator_update(self) -> None: + """Get the latest data and updates the state.""" + self._update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index ef98ce99bfb..369e952c1be 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -11,13 +11,13 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -82,10 +82,8 @@ async def async_setup_entry( class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" - @callback - def _handle_coordinator_update(self) -> None: - """Get the latest data and updates the states.""" - LOGGER.debug("Updating Hydrawise sensor: %s", self.name) + def _update_attrs(self) -> None: + """Update state attributes.""" relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] if self.entity_description.key == "watering_time": if relay_data["timestr"] == "Now": @@ -94,8 +92,6 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): self._attr_native_value = 0 else: # _sensor_type == 'next_cycle' next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) - LOGGER.debug("New cycle time: %s", next_cycle) self._attr_native_value = dt_util.utc_from_timestamp( dt_util.as_timestamp(dt_util.now()) + next_cycle ) - super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index d1ea0233145..caaefd7aa26 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -23,7 +23,6 @@ from .const import ( CONF_WATERING_TIME, DEFAULT_WATERING_TIME, DOMAIN, - LOGGER, ) from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -124,14 +123,11 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): elif self.entity_description.key == "auto_watering": self.coordinator.api.suspend_zone(365, zone_number) - @callback - def _handle_coordinator_update(self) -> None: - """Update device state.""" + def _update_attrs(self) -> None: + """Update state attributes.""" zone_number = self.data["relay"] - LOGGER.debug("Updating Hydrawise switch: %s", self.name) timestr = self.coordinator.api.relays_by_zone_number[zone_number]["timestr"] if self.entity_description.key == "manual_watering": self._attr_is_on = timestr == "Now" elif self.entity_description.key == "auto_watering": self._attr_is_on = timestr not in {"", "Now"} - super()._handle_coordinator_update() diff --git a/tests/components/hydrawise/test_binary_sensor.py b/tests/components/hydrawise/test_binary_sensor.py index ab88c5fb750..c60f4392f1e 100644 --- a/tests/components/hydrawise/test_binary_sensor.py +++ b/tests/components/hydrawise/test_binary_sensor.py @@ -17,11 +17,6 @@ async def test_states( freezer: FrozenDateTimeFactory, ) -> None: """Test binary_sensor states.""" - # Make the coordinator refresh data. - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - connectivity = hass.states.get("binary_sensor.home_controller_connectivity") assert connectivity is not None assert connectivity.state == "on" diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py index 87c158ec0b9..79cea94d479 100644 --- a/tests/components/hydrawise/test_init.py +++ b/tests/components/hydrawise/test_init.py @@ -1,6 +1,6 @@ """Tests for the Hydrawise integration.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock from requests.exceptions import HTTPError @@ -15,6 +15,7 @@ from tests.common import MockConfigEntry async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) -> None: """Test that setup with a YAML config triggers an import and warning.""" + mock_pydrawise.update_controller_info.return_value = True mock_pydrawise.customer_id = 12345 mock_pydrawise.status = "unknown" config = {"hydrawise": {CONF_ACCESS_TOKEN: "_access-token_"}} @@ -29,29 +30,22 @@ async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) - async def test_connect_retry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock ) -> None: """Test that a connection error triggers a retry.""" - with patch("pydrawise.legacy.LegacyHydrawise") as mock_api: - mock_api.side_effect = HTTPError - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - mock_api.assert_called_once() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_pydrawise.update_controller_info.side_effect = HTTPError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_setup_no_data( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock ) -> None: """Test that no data from the API triggers a retry.""" - with patch("pydrawise.legacy.LegacyHydrawise") as mock_api: - mock_api.return_value.controller_info = {} - mock_api.return_value.controller_status = None - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - mock_api.assert_called_once() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_pydrawise.update_controller_info.return_value = False + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/hydrawise/test_sensor.py b/tests/components/hydrawise/test_sensor.py index b7c60f333f4..c6d3fecab65 100644 --- a/tests/components/hydrawise/test_sensor.py +++ b/tests/components/hydrawise/test_sensor.py @@ -1,14 +1,11 @@ """Test Hydrawise sensor.""" -from datetime import timedelta - from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.hydrawise.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry @pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") @@ -18,11 +15,6 @@ async def test_states( freezer: FrozenDateTimeFactory, ) -> None: """Test sensor states.""" - # Make the coordinator refresh data. - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - watering_time1 = hass.states.get("sensor.zone_one_watering_time") assert watering_time1 is not None assert watering_time1.state == "0" @@ -33,4 +25,4 @@ async def test_states( next_cycle = hass.states.get("sensor.zone_one_next_cycle") assert next_cycle is not None - assert next_cycle.state == "2023-10-04T19:52:27+00:00" + assert next_cycle.state == "2023-10-04T19:49:57+00:00" diff --git a/tests/components/hydrawise/test_switch.py b/tests/components/hydrawise/test_switch.py index 615a336ee5f..39d789f4cf9 100644 --- a/tests/components/hydrawise/test_switch.py +++ b/tests/components/hydrawise/test_switch.py @@ -1,16 +1,14 @@ """Test Hydrawise switch.""" -from datetime import timedelta from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.hydrawise.const import SCAN_INTERVAL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry async def test_states( @@ -19,11 +17,6 @@ async def test_states( freezer: FrozenDateTimeFactory, ) -> None: """Test switch states.""" - # Make the coordinator refresh data. - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - watering1 = hass.states.get("switch.zone_one_manual_watering") assert watering1 is not None assert watering1.state == "off" From 12482216f614ea79035e2d16d5765d57f96ae851 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Mon, 30 Oct 2023 07:36:34 -0400 Subject: [PATCH 044/643] Fix Google Mail expired authorization (#102735) * Fix Google Mail expired authorization * add test * raise HomeAssistantError * handle in api module * uno mas --- .../components/google_mail/__init__.py | 14 +------- homeassistant/components/google_mail/api.py | 35 +++++++++++++++---- tests/components/google_mail/test_init.py | 7 +++- tests/components/google_mail/test_services.py | 15 ++++++-- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 15c4192ccf5..96639e4a547 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -1,12 +1,9 @@ """Support for Google Mail.""" from __future__ import annotations -from aiohttp.client_exceptions import ClientError, ClientResponseError - from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -35,16 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) auth = AsyncConfigEntryAuth(session) - try: - await auth.check_and_refresh_token() - except ClientResponseError as err: - if 400 <= err.status < 500: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" - ) from err - raise ConfigEntryNotReady from err - except ClientError as err: - raise ConfigEntryNotReady from err + await auth.check_and_refresh_token() hass.data[DOMAIN][entry.entry_id] = auth hass.async_create_task( diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index ffa33deae14..10b2fec7467 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -1,9 +1,16 @@ """API for Google Mail bound to Home Assistant OAuth.""" +from aiohttp.client_exceptions import ClientError, ClientResponseError from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers import config_entry_oauth2_flow @@ -24,14 +31,30 @@ class AsyncConfigEntryAuth: async def check_and_refresh_token(self) -> str: """Check the token.""" - await self.oauth_session.async_ensure_token_valid() + try: + await self.oauth_session.async_ensure_token_valid() + except (RefreshError, ClientResponseError, ClientError) as ex: + if ( + self.oauth_session.config_entry.state + is ConfigEntryState.SETUP_IN_PROGRESS + ): + if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from ex + raise ConfigEntryNotReady from ex + if ( + isinstance(ex, RefreshError) + or hasattr(ex, "status") + and ex.status == 400 + ): + self.oauth_session.config_entry.async_start_reauth( + self.oauth_session.hass + ) + raise HomeAssistantError(ex) from ex return self.access_token async def get_resource(self) -> Resource: """Get current resource.""" - try: - credentials = Credentials(await self.check_and_refresh_token()) - except RefreshError as ex: - self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass) - raise ex + credentials = Credentials(await self.check_and_refresh_token()) return build("gmail", "v1", credentials=credentials) diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py index a069ae0807b..4882fd10e80 100644 --- a/tests/components/google_mail/test_init.py +++ b/tests/components/google_mail/test_init.py @@ -73,8 +73,13 @@ async def test_expired_token_refresh_success( http.HTTPStatus.INTERNAL_SERVER_ERROR, ConfigEntryState.SETUP_RETRY, ), + ( + time.time() - 3600, + http.HTTPStatus.BAD_REQUEST, + ConfigEntryState.SETUP_ERROR, + ), ], - ids=["failure_requires_reauth", "transient_failure"], + ids=["failure_requires_reauth", "transient_failure", "revoked_auth"], ) async def test_expired_token_refresh_failure( hass: HomeAssistant, diff --git a/tests/components/google_mail/test_services.py b/tests/components/google_mail/test_services.py index b9fefa805e6..caa0d887dec 100644 --- a/tests/components/google_mail/test_services.py +++ b/tests/components/google_mail/test_services.py @@ -1,12 +1,14 @@ """Services tests for the Google Mail integration.""" from unittest.mock import patch +from aiohttp.client_exceptions import ClientResponseError from google.auth.exceptions import RefreshError import pytest from homeassistant import config_entries from homeassistant.components.google_mail import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import BUILD, SENSOR, TOKEN, ComponentSetup @@ -57,13 +59,22 @@ async def test_set_vacation( assert len(mock_client.mock_calls) == 5 +@pytest.mark.parametrize( + ("side_effect"), + ( + (RefreshError,), + (ClientResponseError("", (), status=400),), + ), +) async def test_reauth_trigger( - hass: HomeAssistant, setup_integration: ComponentSetup + hass: HomeAssistant, + setup_integration: ComponentSetup, + side_effect, ) -> None: """Test reauth is triggered after a refresh error during service call.""" await setup_integration() - with patch(TOKEN, side_effect=RefreshError), pytest.raises(RefreshError): + with patch(TOKEN, side_effect=side_effect), pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, "set_vacation", From cc3ae9e10304e217e5c48ce22f0d2860b2e8f057 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 29 Oct 2023 09:17:57 +0100 Subject: [PATCH 045/643] Correct total state_class of huisbaasje sensors (#102945) * Change all cumulative-interval sensors to TOTAL --- homeassistant/components/huisbaasje/sensor.py | 16 ++++----- tests/components/huisbaasje/test_sensor.py | 34 +++++-------------- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 6e3f5eaee33..b82b2b34a4b 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -146,7 +146,7 @@ SENSORS_INFO = [ translation_key="energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_DAY, precision=1, @@ -156,7 +156,7 @@ SENSORS_INFO = [ translation_key="energy_week", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_WEEK, precision=1, @@ -166,7 +166,7 @@ SENSORS_INFO = [ translation_key="energy_month", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_MONTH, precision=1, @@ -176,7 +176,7 @@ SENSORS_INFO = [ translation_key="energy_year", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_YEAR, precision=1, @@ -197,7 +197,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_DAY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -207,7 +207,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_WEEK, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -217,7 +217,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_MONTH, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -227,7 +227,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_YEAR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index 484dc8bac48..3f0bdae8e53 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -222,10 +222,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: energy_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY ) assert energy_today.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" - assert ( - energy_today.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert energy_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( energy_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR @@ -239,8 +236,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_week.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_week.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -255,8 +251,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_month.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_month.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -271,8 +266,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_year.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_year.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -295,10 +289,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_today.state == "1.1" assert gas_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_today.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_today.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -308,10 +299,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_week.state == "5.6" assert gas_this_week.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_week.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_week.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -321,10 +309,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_month.state == "39.1" assert gas_this_month.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_month.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_month.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -334,10 +319,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_year.state == "116.7" assert gas_this_year.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_year.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_year.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS From 36512f71573e18ea1204a4d87a10e727c57dd22d Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 29 Oct 2023 00:05:37 -0700 Subject: [PATCH 046/643] Bump opower to 0.0.38 (#102983) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 88e03842504..a27d6f6f680 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.37"] + "requirements": ["opower==0.0.38"] } diff --git a/requirements_all.txt b/requirements_all.txt index b69dbf4c258..c2c34d359e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1394,7 +1394,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.37 +opower==0.0.38 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ec473c9ef1..f6005b77f3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1072,7 +1072,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.37 +opower==0.0.38 # homeassistant.components.oralb oralb-ble==0.17.6 From 5ac7e8b1ac2eb5c2217ad73b44969bcf5cd2c4e1 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 29 Oct 2023 13:28:35 +0100 Subject: [PATCH 047/643] Harden evohome against failures to retrieve high-precision temps (#102989) fix hass-logger-period --- homeassistant/components/evohome/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 2aa0cd42fe1..4b79ef3df1b 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -487,6 +487,18 @@ class EvoBroker: ) self.temps = None # these are now stale, will fall back to v2 temps + except KeyError as err: + _LOGGER.warning( + ( + "Unable to obtain high-precision temperatures. " + "It appears the JSON schema is not as expected, " + "so the high-precision feature will be disabled until next restart." + "Message is: %s" + ), + err, + ) + self.client_v1 = self.temps = None + else: if ( str(self.client_v1.location_id) @@ -495,7 +507,7 @@ class EvoBroker: _LOGGER.warning( "The v2 API's configured location doesn't match " "the v1 API's default location (there is more than one location), " - "so the high-precision feature will be disabled" + "so the high-precision feature will be disabled until next restart" ) self.client_v1 = self.temps = None else: From fefe930506814cfdf89715ca6eabe1cc376de553 Mon Sep 17 00:00:00 2001 From: Tom Puttemans Date: Sun, 29 Oct 2023 10:23:24 +0100 Subject: [PATCH 048/643] DSMR Gas currently delivered device state class conflict (#102991) Fixes #102985 --- homeassistant/components/dsmr_reader/definitions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index d89e30311e9..f12b2ad72bc 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -210,7 +210,6 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", translation_key="current_gas_usage", - device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.MEASUREMENT, ), From e81bfb959e0e71f5c0c2fcc554eb1750847f3941 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 29 Oct 2023 10:43:57 +0100 Subject: [PATCH 049/643] Fix proximity entity id (#102992) * fix proximity entity id * extend test to cover entity id --- homeassistant/components/proximity/__init__.py | 2 +- tests/components/proximity/test_init.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 07b5f931f79..23a8fc3bf64 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -88,7 +88,7 @@ def async_setup_proximity_component( proximity_zone, unit_of_measurement, ) - proximity.entity_id = f"{DOMAIN}.{proximity_zone}" + proximity.entity_id = f"{DOMAIN}.{zone_friendly_name}" proximity.async_write_ha_state() diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 0ec8765e604..cd96d0d7b81 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -13,6 +13,11 @@ async def test_proximities(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, + "home_test2": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1", "device_tracker.test2"], + "tolerance": "1", + }, "work": { "devices": ["device_tracker.test1"], "tolerance": "1", @@ -23,7 +28,7 @@ async def test_proximities(hass: HomeAssistant) -> None: assert await async_setup_component(hass, DOMAIN, config) - proximities = ["home", "work"] + proximities = ["home", "home_test2", "work"] for prox in proximities: state = hass.states.get(f"proximity.{prox}") From 13580a334f37586bfbfe6b2b40d77cae7fe4683f Mon Sep 17 00:00:00 2001 From: Nortonko <52453201+Nortonko@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:32:24 +0100 Subject: [PATCH 050/643] Bump python-androidtv to 0.0.73 (#102999) * Update manifest.json Bump python-androidtv to version 0.0.73 * bump androidtv 0.0.73 * bump androidtv 0.0.73 --- homeassistant/components/androidtv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index b8c020e6e1e..2d0b062c750 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -9,7 +9,7 @@ "loggers": ["adb_shell", "androidtv", "pure_python_adb"], "requirements": [ "adb-shell[async]==0.4.4", - "androidtv[async]==0.0.72", + "androidtv[async]==0.0.73", "pure-python-adb[async]==0.3.0.dev0" ] } diff --git a/requirements_all.txt b/requirements_all.txt index c2c34d359e3..b248c8f1dab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -414,7 +414,7 @@ amberelectric==1.0.4 amcrest==1.9.8 # homeassistant.components.androidtv -androidtv[async]==0.0.72 +androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6005b77f3c..cf6c995b317 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -383,7 +383,7 @@ airtouch4pyapi==1.0.5 amberelectric==1.0.4 # homeassistant.components.androidtv -androidtv[async]==0.0.72 +androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 From 031b1c26ce428b9c4652d4f011c1f4dbf8975d41 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 30 Oct 2023 08:46:20 +0000 Subject: [PATCH 051/643] Fix utility_meter reset when DST change occurs (#103012) --- .../components/utility_meter/sensor.py | 24 ++++++++++--------- tests/components/utility_meter/test_sensor.py | 14 ++++++++++- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index cd581d8c37f..794a65db03a 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -534,16 +534,25 @@ class UtilityMeterSensor(RestoreSensor): self.async_write_ha_state() - async def _async_reset_meter(self, event): - """Determine cycle - Helper function for larger than daily cycles.""" + async def _program_reset(self): + """Program the reset of the utility meter.""" if self._cron_pattern is not None: + tz = dt_util.get_time_zone(self.hass.config.time_zone) self.async_on_remove( async_track_point_in_time( self.hass, self._async_reset_meter, - croniter(self._cron_pattern, dt_util.now()).get_next(datetime), + croniter(self._cron_pattern, dt_util.now(tz)).get_next( + datetime + ), # we need timezone for DST purposes (see issue #102984) ) ) + + async def _async_reset_meter(self, event): + """Reset the utility meter status.""" + + await self._program_reset() + await self.async_reset_meter(self._tariff_entity) async def async_reset_meter(self, entity_id): @@ -566,14 +575,7 @@ class UtilityMeterSensor(RestoreSensor): """Handle entity which will be added.""" await super().async_added_to_hass() - if self._cron_pattern is not None: - self.async_on_remove( - async_track_point_in_time( - self.hass, - self._async_reset_meter, - croniter(self._cron_pattern, dt_util.now()).get_next(datetime), - ) - ) + await self._program_reset() self.async_on_remove( async_dispatcher_connect( diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 43d68d87362..2c64338c4f3 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1266,7 +1266,9 @@ async def _test_self_reset( state = hass.states.get("sensor.energy_bill") if expect_reset: assert state.attributes.get("last_period") == "2" - assert state.attributes.get("last_reset") == now.isoformat() + assert ( + state.attributes.get("last_reset") == dt_util.as_utc(now).isoformat() + ) # last_reset is kept in UTC assert state.state == "3" else: assert state.attributes.get("last_period") == "0" @@ -1348,6 +1350,16 @@ async def test_self_reset_hourly(hass: HomeAssistant) -> None: ) +async def test_self_reset_hourly_dst(hass: HomeAssistant) -> None: + """Test hourly reset of meter in DST change conditions.""" + + hass.config.time_zone = "Europe/Lisbon" + dt_util.set_default_time_zone(dt_util.get_time_zone(hass.config.time_zone)) + await _test_self_reset( + hass, gen_config("hourly"), "2023-10-29T01:59:00.000000+00:00" + ) + + async def test_self_reset_daily(hass: HomeAssistant) -> None: """Test daily reset of meter.""" await _test_self_reset( From 70e89781234df8cc7049a8d76d708e740364d489 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 29 Oct 2023 12:44:15 -0400 Subject: [PATCH 052/643] Fix zwave_js siren name (#103016) * Fix zwave_js.siren name * Fix test --- homeassistant/components/zwave_js/entity.py | 2 +- homeassistant/components/zwave_js/siren.py | 2 ++ tests/components/zwave_js/test_siren.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 0b9c68e9664..e7e110e7db6 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -206,7 +206,7 @@ class ZWaveBaseEntity(Entity): ): name += f" ({primary_value.endpoint})" - return name + return name.strip() @property def available(self) -> bool: diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index 6de6b0f4e45..7df88f7dca4 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -72,6 +72,8 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): if self._attr_available_tones: self._attr_supported_features |= SirenEntityFeature.TONES + self._attr_name = self.generate_name(include_value_name=True) + @property def is_on(self) -> bool | None: """Return whether device is on.""" diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 210339e22d7..6df5881107a 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -9,7 +9,7 @@ from homeassistant.components.siren import ( from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -SIREN_ENTITY = "siren.indoor_siren_6_2" +SIREN_ENTITY = "siren.indoor_siren_6_play_tone_2" TONE_ID_VALUE_ID = { "endpoint": 2, From f70c13214cae287f91be7f8c79432eb53274b393 Mon Sep 17 00:00:00 2001 From: kpine Date: Sun, 29 Oct 2023 11:15:19 -0700 Subject: [PATCH 053/643] Revert "Fix temperature setting for multi-setpoint z-wave device (#102395)" (#103022) This reverts commit 2d6dc2bcccff7518366655a67947d73506fc1e50. --- homeassistant/components/zwave_js/climate.py | 8 +- tests/components/zwave_js/conftest.py | 14 - .../climate_intermatic_pe653_state.json | 4508 ----------------- tests/components/zwave_js/test_climate.py | 193 - 4 files changed, 3 insertions(+), 4720 deletions(-) delete mode 100644 tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 28084eecfa6..d511a030fb1 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -259,11 +259,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): def _current_mode_setpoint_enums(self) -> list[ThermostatSetpointType]: """Return the list of enums that are relevant to the current thermostat mode.""" if self._current_mode is None or self._current_mode.value is None: - # Thermostat with no support for setting a mode is just a setpoint - if self.info.primary_value.property_key is None: - return [] - return [ThermostatSetpointType(int(self.info.primary_value.property_key))] - + # Thermostat(valve) with no support for setting a mode + # is considered heating-only + return [ThermostatSetpointType.HEATING] return THERMOSTAT_MODE_SETPOINT_MAP.get(int(self._current_mode.value), []) @property diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 534f2fd2457..5a424b38c5b 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -662,12 +662,6 @@ def logic_group_zdb5100_state_fixture(): return json.loads(load_fixture("zwave_js/logic_group_zdb5100_state.json")) -@pytest.fixture(name="climate_intermatic_pe653_state", scope="session") -def climate_intermatic_pe653_state_fixture(): - """Load Intermatic PE653 Pool Control node state fixture data.""" - return json.loads(load_fixture("zwave_js/climate_intermatic_pe653_state.json")) - - @pytest.fixture(name="central_scene_node_state", scope="session") def central_scene_node_state_fixture(): """Load node with Central Scene CC node state fixture data.""" @@ -1312,14 +1306,6 @@ def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state): return node -@pytest.fixture(name="climate_intermatic_pe653") -def climate_intermatic_pe653_fixture(client, climate_intermatic_pe653_state): - """Mock an Intermatic PE653 node.""" - node = Node(client, copy.deepcopy(climate_intermatic_pe653_state)) - client.driver.controller.nodes[node.node_id] = node - return node - - @pytest.fixture(name="central_scene_node") def central_scene_node_fixture(client, central_scene_node_state): """Mock a node with the Central Scene CC.""" diff --git a/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json b/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json deleted file mode 100644 index a5e86b9c013..00000000000 --- a/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json +++ /dev/null @@ -1,4508 +0,0 @@ -{ - "nodeId": 19, - "index": 0, - "status": 4, - "ready": true, - "isListening": true, - "isRouting": true, - "isSecure": false, - "manufacturerId": 5, - "productId": 1619, - "productType": 20549, - "firmwareVersion": "3.9", - "deviceConfig": { - "filename": "/data/db/devices/0x0005/pe653.json", - "isEmbedded": true, - "manufacturer": "Intermatic", - "manufacturerId": 5, - "label": "PE653", - "description": "Pool Control", - "devices": [ - { - "productType": 20549, - "productId": 1619 - } - ], - "firmwareVersion": { - "min": "0.0", - "max": "255.255" - }, - "preferred": false, - "associations": {}, - "paramInformation": { - "_map": {} - }, - "compat": { - "addCCs": {}, - "overrideQueries": { - "overrides": {} - } - } - }, - "label": "PE653", - "endpointCountIsDynamic": false, - "endpointsHaveIdenticalCapabilities": false, - "individualEndpointCount": 39, - "aggregatedEndpointCount": 0, - "interviewAttempts": 1, - "endpoints": [ - { - "nodeId": 19, - "index": 0, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 145, - "name": "Manufacturer Proprietary", - "version": 1, - "isSecure": false - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": false - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 1, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 1, - "isSecure": false - }, - { - "id": 129, - "name": "Clock", - "version": 1, - "isSecure": false - }, - { - "id": 96, - "name": "Multi Channel", - "version": 1, - "isSecure": false - }, - { - "id": 112, - "name": "Configuration", - "version": 1, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 1, - "isSecure": false - }, - { - "id": 67, - "name": "Thermostat Setpoint", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 48, - "name": "Binary Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 1, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 145, - "name": "Manufacturer Proprietary", - "version": 1, - "isSecure": false - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": false - }, - { - "id": 112, - "name": "Configuration", - "version": 1, - "isSecure": false - }, - { - "id": 67, - "name": "Thermostat Setpoint", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 48, - "name": "Binary Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 1, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 1, - "isSecure": false - }, - { - "id": 129, - "name": "Clock", - "version": 1, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 2, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 3, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 4, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 5, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 6, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 7, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 8, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 9, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 10, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 11, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 12, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 13, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 14, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 15, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 16, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 17, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 18, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 19, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 20, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 21, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 22, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 23, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 24, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 25, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 26, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 27, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 28, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 29, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 30, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 31, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 32, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 33, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 34, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 35, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 36, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 37, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 38, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 39, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - } - ], - "values": [ - { - "endpoint": 0, - "commandClass": 67, - "commandClassName": "Thermostat Setpoint", - "property": "setpoint", - "propertyKey": 7, - "propertyName": "setpoint", - "propertyKeyName": "Furnace", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Setpoint (Furnace)", - "ccSpecific": { - "setpointType": 7 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 60 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 2, - "propertyName": "Installed Pump Type", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Installed Pump Type", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "One Speed", - "1": "Two Speed" - }, - "valueSize": 2, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Installed Pump Type" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 1, - "propertyName": "Booster (Cleaner) Pump Installed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Booster (Cleaner) Pump Installed", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 2, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Booster (Cleaner) Pump Installed" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 65280, - "propertyName": "Booster (Cleaner) Pump Operation Mode", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Set the filter pump mode to use when the booster (cleaner) pump is running.", - "label": "Booster (Cleaner) Pump Operation Mode", - "default": 1, - "min": 1, - "max": 6, - "states": { - "1": "Disable", - "2": "Circuit 1", - "3": "VSP Speed 1", - "4": "VSP Speed 2", - "5": "VSP Speed 3", - "6": "VSP Speed 4" - }, - "valueSize": 2, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Booster (Cleaner) Pump Operation Mode", - "info": "Set the filter pump mode to use when the booster (cleaner) pump is running." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 2, - "propertyKey": 65280, - "propertyName": "Heater Cooldown Period", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Heater Cooldown Period", - "default": -1, - "min": -1, - "max": 15, - "states": { - "0": "Heater installed with no cooldown", - "-1": "No heater installed" - }, - "unit": "minutes", - "valueSize": 2, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Heater Cooldown Period" - }, - "value": 2 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 2, - "propertyKey": 1, - "propertyName": "Heater Safety Setting", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Prevent the heater from turning on while the pump is off.", - "label": "Heater Safety Setting", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 2, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Heater Safety Setting", - "info": "Prevent the heater from turning on while the pump is off." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 4278190080, - "propertyName": "Water Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Water Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Water Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 16711680, - "propertyName": "Air/Freeze Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Air/Freeze Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Air/Freeze Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 65280, - "propertyName": "Solar Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Solar Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Solar Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 22, - "propertyName": "Pool/Spa Configuration", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Pool/Spa Configuration", - "default": 0, - "min": 0, - "max": 2, - "states": { - "0": "Pool", - "1": "Spa", - "2": "Both" - }, - "valueSize": 1, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Pool/Spa Configuration" - }, - "value": 2 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 23, - "propertyName": "Spa Mode Pump Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires pool/spa configuration.", - "label": "Spa Mode Pump Speed", - "default": 1, - "min": 1, - "max": 6, - "states": { - "1": "Disabled", - "2": "Circuit 1", - "3": "VSP Speed 1", - "4": "VSP Speed 2", - "5": "VSP Speed 3", - "6": "VSP Speed 4" - }, - "valueSize": 1, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Spa Mode Pump Speed", - "info": "Requires pool/spa configuration." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 32, - "propertyName": "Variable Speed Pump - Speed 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 1", - "default": 750, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 1", - "info": "Requires connected variable speed pump." - }, - "value": 1400 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 33, - "propertyName": "Variable Speed Pump - Speed 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 2", - "default": 1500, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 2", - "info": "Requires connected variable speed pump." - }, - "value": 1700 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 34, - "propertyName": "Variable Speed Pump - Speed 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 3", - "default": 2350, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 3", - "info": "Requires connected variable speed pump." - }, - "value": 2500 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 35, - "propertyName": "Variable Speed Pump - Speed 4", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 4", - "default": 3110, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 4", - "info": "Requires connected variable speed pump." - }, - "value": 2500 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 49, - "propertyName": "Variable Speed Pump - Max Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Max Speed", - "default": 3450, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Max Speed", - "info": "Requires connected variable speed pump." - }, - "value": 3000 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 4278190080, - "propertyName": "Freeze Protection: Temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Temperature", - "default": 0, - "min": 0, - "max": 44, - "states": { - "0": "Disabled", - "40": "40 °F", - "41": "41 °F", - "42": "42 °F", - "43": "43 °F", - "44": "44 °F" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Temperature" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 65536, - "propertyName": "Freeze Protection: Turn On Circuit 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 1", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 1" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 131072, - "propertyName": "Freeze Protection: Turn On Circuit 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 2", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 2" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 262144, - "propertyName": "Freeze Protection: Turn On Circuit 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 3", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 3" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 524288, - "propertyName": "Freeze Protection: Turn On Circuit 4", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 4", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 4" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 1048576, - "propertyName": "Freeze Protection: Turn On Circuit 5", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 5", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 5" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 65280, - "propertyName": "Freeze Protection: Turn On VSP Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires variable speed pump and connected air/freeze sensor.", - "label": "Freeze Protection: Turn On VSP Speed", - "default": 0, - "min": 0, - "max": 5, - "states": { - "0": "None", - "2": "VSP Speed 1", - "3": "VSP Speed 2", - "4": "VSP Speed 3", - "5": "VSP Speed 4" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On VSP Speed", - "info": "Requires variable speed pump and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 128, - "propertyName": "Freeze Protection: Turn On Heater", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires heater and connected air/freeze sensor.", - "label": "Freeze Protection: Turn On Heater", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Heater", - "info": "Requires heater and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 127, - "propertyName": "Freeze Protection: Pool/Spa Cycle Time", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires pool/spa configuration and connected air/freeze sensor.", - "label": "Freeze Protection: Pool/Spa Cycle Time", - "default": 0, - "min": 0, - "max": 30, - "unit": "minutes", - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Freeze Protection: Pool/Spa Cycle Time", - "info": "Requires pool/spa configuration and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 4, - "propertyName": "Circuit 1 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable.", - "label": "Circuit 1 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 1", - "info": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable." - }, - "value": 1979884035 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 5, - "propertyName": "Circuit 1 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 1 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 6, - "propertyName": "Circuit 1 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 1 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 7, - "propertyName": "Circuit 2 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 8, - "propertyName": "Circuit 2 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 9, - "propertyName": "Circuit 2 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 10, - "propertyName": "Circuit 3 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 11, - "propertyName": "Circuit 3 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 12, - "propertyName": "Circuit 3 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 13, - "propertyName": "Circuit 4 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 14, - "propertyName": "Circuit 4 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 15, - "propertyName": "Circuit 4 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 16, - "propertyName": "Circuit 5 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 17, - "propertyName": "Circuit 5 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 18, - "propertyName": "Circuit 5 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 19, - "propertyName": "Pool/Spa Mode Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 20, - "propertyName": "Pool/Spa Mode Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 21, - "propertyName": "Pool/Spa Mode Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 36, - "propertyName": "Variable Speed Pump Speed 1 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 37, - "propertyName": "Variable Speed Pump Speed 1 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 38, - "propertyName": "Variable Speed Pump Speed 1 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 39, - "propertyName": "Variable Speed Pump Speed 2 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 1476575235 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 40, - "propertyName": "Variable Speed Pump Speed 2 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 41, - "propertyName": "Variable Speed Pump Speed 2 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 42, - "propertyName": "Variable Speed Pump Speed 3 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 43, - "propertyName": "Variable Speed Pump Speed 3 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 44, - "propertyName": "Variable Speed Pump Speed 3 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 45, - "propertyName": "Variable Speed Pump Speed 4 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 46, - "propertyName": "Variable Speed Pump Speed 4 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 47, - "propertyName": "Variable Speed Pump Speed 4 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "manufacturerId", - "propertyName": "manufacturerId", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Manufacturer ID", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 5 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productType", - "propertyName": "productType", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Product type", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 20549 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productId", - "propertyName": "productId", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Product ID", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 1619 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "libraryType", - "propertyName": "libraryType", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Library type", - "states": { - "0": "Unknown", - "1": "Static Controller", - "2": "Controller", - "3": "Enhanced Slave", - "4": "Slave", - "5": "Installer", - "6": "Routing Slave", - "7": "Bridge Controller", - "8": "Device under Test", - "9": "N/A", - "10": "AV Remote", - "11": "AV Device" - }, - "stateful": true, - "secret": false - }, - "value": 6 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "protocolVersion", - "propertyName": "protocolVersion", - "ccVersion": 1, - "metadata": { - "type": "string", - "readable": true, - "writeable": false, - "label": "Z-Wave protocol version", - "stateful": true, - "secret": false - }, - "value": "2.78" - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "firmwareVersions", - "propertyName": "firmwareVersions", - "ccVersion": 1, - "metadata": { - "type": "string[]", - "readable": true, - "writeable": false, - "label": "Z-Wave chip firmware versions", - "stateful": true, - "secret": false - }, - "value": ["3.9"] - }, - { - "endpoint": 1, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 1, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 1, - "commandClass": 48, - "commandClassName": "Binary Sensor", - "property": "Any", - "propertyName": "Any", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Sensor state (Any)", - "ccSpecific": { - "sensorType": 255 - }, - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 1, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 81, - "nodeId": 19 - }, - { - "endpoint": 1, - "commandClass": 67, - "commandClassName": "Thermostat Setpoint", - "property": "setpoint", - "propertyKey": 1, - "propertyName": "setpoint", - "propertyKeyName": "Heating", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Setpoint (Heating)", - "ccSpecific": { - "setpointType": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 39 - }, - { - "endpoint": 2, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 2, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 2, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 84, - "nodeId": 19 - }, - { - "endpoint": 3, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 3, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 3, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 86, - "nodeId": 19 - }, - { - "endpoint": 4, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 4, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 4, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 80, - "nodeId": 19 - }, - { - "endpoint": 5, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 5, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 5, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 83, - "nodeId": 19 - }, - { - "endpoint": 6, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 6, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 7, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 7, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 8, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 8, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 9, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 9, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 10, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 10, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 11, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 11, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 12, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 12, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 13, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 13, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 14, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 14, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 15, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 15, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 16, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 16, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 17, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 17, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 18, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 18, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 19, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 19, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 20, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 20, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 21, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 21, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 22, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 22, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 23, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 23, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 24, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 24, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 25, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 25, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 26, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 26, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 27, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 27, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 28, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 28, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 29, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 29, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 30, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 30, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 31, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 31, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 32, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 32, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 33, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 33, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 34, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 34, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 35, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 35, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 36, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 36, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 37, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 37, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 38, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 38, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 39, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 39, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - } - ], - "isFrequentListening": false, - "maxDataRate": 40000, - "supportedDataRates": [40000], - "protocolVersion": 2, - "supportsBeaming": true, - "supportsSecurity": false, - "nodeType": 1, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "interviewStage": "Complete", - "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0005:0x5045:0x0653:3.9", - "highestSecurityClass": -1, - "isControllerNode": false, - "keepAwake": false -} diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index cdc1e9959a7..e9040dfd397 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -792,196 +792,3 @@ async def test_thermostat_raise_repair_issue_and_warning_when_setting_fan_preset "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" in caplog.text ) - - -async def test_multi_setpoint_thermostat( - hass: HomeAssistant, client, climate_intermatic_pe653, integration -) -> None: - """Test a thermostat with multiple setpoints.""" - node = climate_intermatic_pe653 - - heating_entity_id = "climate.pool_control_2" - heating = hass.states.get(heating_entity_id) - assert heating - assert heating.state == HVACMode.HEAT - assert heating.attributes[ATTR_TEMPERATURE] == 3.9 - assert heating.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - assert ( - heating.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - ) - - furnace_entity_id = "climate.pool_control" - furnace = hass.states.get(furnace_entity_id) - assert furnace - assert furnace.state == HVACMode.HEAT - assert furnace.attributes[ATTR_TEMPERATURE] == 15.6 - assert furnace.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - assert ( - furnace.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - ) - - client.async_send_command_no_wait.reset_mock() - - # Test setting temperature of heating setpoint - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_TEMPERATURE: 20.0, - }, - blocking=True, - ) - - # Test setting temperature of furnace setpoint - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_TEMPERATURE: 2.0, - }, - blocking=True, - ) - - # Test setting illegal mode raises an error - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - - # this is a no-op since there's no mode - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_HVAC_MODE: HVACMode.HEAT, - }, - blocking=True, - ) - - # this is a no-op since there's no mode - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_HVAC_MODE: HVACMode.HEAT, - }, - blocking=True, - ) - - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == 19 - assert args["valueId"] == { - "endpoint": 1, - "commandClass": 67, - "property": "setpoint", - "propertyKey": 1, - } - assert args["value"] == 68.0 - - args = client.async_send_command.call_args_list[1][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == 19 - assert args["valueId"] == { - "endpoint": 0, - "commandClass": 67, - "property": "setpoint", - "propertyKey": 7, - } - assert args["value"] == 35.6 - - client.async_send_command.reset_mock() - - # Test heating setpoint value update from value updated event - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": 19, - "args": { - "commandClassName": "Thermostat Setpoint", - "commandClass": 67, - "endpoint": 1, - "property": "setpoint", - "propertyKey": 1, - "propertyKeyName": "Heating", - "propertyName": "setpoint", - "newValue": 23, - "prevValue": 21.5, - }, - }, - ) - node.receive_event(event) - - state = hass.states.get(heating_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == -5 - - # furnace not changed - state = hass.states.get(furnace_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 15.6 - - client.async_send_command.reset_mock() - - # Test furnace setpoint value update from value updated event - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": 19, - "args": { - "commandClassName": "Thermostat Setpoint", - "commandClass": 67, - "endpoint": 0, - "property": "setpoint", - "propertyKey": 7, - "propertyKeyName": "Furnace", - "propertyName": "setpoint", - "newValue": 68, - "prevValue": 21.5, - }, - }, - ) - node.receive_event(event) - - # heating not changed - state = hass.states.get(heating_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == -5 - - state = hass.states.get(furnace_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 20 - - client.async_send_command.reset_mock() From f5b3661836cffa5e6b4c94e78018b27eda4309a0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Oct 2023 14:26:10 -0700 Subject: [PATCH 054/643] Fix bug in fitbit credential import for expired tokens (#103024) * Fix bug in fitbit credential import on token refresh * Use stable test ids * Update homeassistant/components/fitbit/sensor.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/fitbit/sensor.py | 11 +++++++---- tests/components/fitbit/conftest.py | 5 +++-- tests/components/fitbit/test_config_flow.py | 18 +++++++++++++----- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 4885c9fa16d..d0d939ce67e 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -581,7 +581,9 @@ async def async_setup_platform( refresh_cb=lambda x: None, ) try: - await hass.async_add_executor_job(authd_client.client.refresh_token) + updated_token = await hass.async_add_executor_job( + authd_client.client.refresh_token + ) except OAuth2Error as err: _LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err) translation_key = "deprecated_yaml_import_issue_cannot_connect" @@ -599,9 +601,10 @@ async def async_setup_platform( data={ "auth_implementation": DOMAIN, CONF_TOKEN: { - ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], - ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], - "expires_at": config_file[ATTR_LAST_SAVED_AT], + ATTR_ACCESS_TOKEN: updated_token[ATTR_ACCESS_TOKEN], + ATTR_REFRESH_TOKEN: updated_token[ATTR_REFRESH_TOKEN], + "expires_at": updated_token["expires_at"], + "scope": " ".join(updated_token.get("scope", [])), }, CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 155e5499543..682fb0edd3b 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -41,10 +41,11 @@ TIMESERIES_API_URL_FORMAT = ( # These constants differ from values in the config entry or fitbit.conf SERVER_ACCESS_TOKEN = { - "refresh_token": "server-access-token", - "access_token": "server-refresh-token", + "refresh_token": "server-refresh-token", + "access_token": "server-access-token", "type": "Bearer", "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), } diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index 152439ec19a..cf2d5d17f22 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus +import time from typing import Any from unittest.mock import patch @@ -16,9 +17,7 @@ from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir from .conftest import ( CLIENT_ID, - FAKE_ACCESS_TOKEN, FAKE_AUTH_IMPL, - FAKE_REFRESH_TOKEN, PROFILE_API_URL, PROFILE_USER_ID, SERVER_ACCESS_TOKEN, @@ -204,6 +203,11 @@ async def test_config_entry_already_exists( assert result.get("reason") == "already_configured" +@pytest.mark.parametrize( + "token_expiration_time", + [time.time() + 86400, time.time() - 86400], + ids=("token_active", "token_expired"), +) async def test_import_fitbit_config( hass: HomeAssistant, fitbit_config_setup: None, @@ -235,16 +239,20 @@ async def test_import_fitbit_config( assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) + # Verify imported values from fitbit.conf and configuration.yaml and + # that the token is updated. assert "token" in data + expires_at = data["token"]["expires_at"] + assert expires_at > time.time() del data["token"]["expires_at"] - # Verify imported values from fitbit.conf and configuration.yaml assert dict(config_entry.data) == { "auth_implementation": DOMAIN, "clock_format": "24H", "monitored_resources": ["activities/steps"], "token": { - "access_token": FAKE_ACCESS_TOKEN, - "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": "server-access-token", + "refresh_token": "server-refresh-token", + "scope": "activity heartrate nutrition profile settings sleep weight", }, "unit_system": "default", } From 6f73d2aac5a79a78fb8c4f652ca66b059c0826f7 Mon Sep 17 00:00:00 2001 From: G-Two <7310260+G-Two@users.noreply.github.com> Date: Mon, 30 Oct 2023 04:46:48 -0400 Subject: [PATCH 055/643] Bump to subarulink 0.7.8 (#103033) --- homeassistant/components/subaru/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 9fae6ca9f73..0c4367c77c8 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.6"] + "requirements": ["subarulink==0.7.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index b248c8f1dab..74749f3eafe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2512,7 +2512,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.6 +subarulink==0.7.8 # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf6c995b317..1c2a04d2b51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1872,7 +1872,7 @@ stookwijzer==1.3.0 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.6 +subarulink==0.7.8 # homeassistant.components.solarlog sunwatcher==0.2.1 From 483671bf9f03aa3411f010886d40dbcb297f3132 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Oct 2023 15:48:01 -0700 Subject: [PATCH 056/643] Bump google-nest-sdm to 3.0.3 (#103035) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index bf24fc4a4e9..89244642207 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==3.0.2"] + "requirements": ["google-nest-sdm==3.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 74749f3eafe..dc5ed080b1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -910,7 +910,7 @@ google-cloud-texttospeech==2.12.3 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==3.0.2 +google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c2a04d2b51..c0d89daaa42 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -726,7 +726,7 @@ google-cloud-pubsub==2.13.11 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==3.0.2 +google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 From 5c16a8247addf7c81498674f9089af312d7592e4 Mon Sep 17 00:00:00 2001 From: Jirka Date: Mon, 30 Oct 2023 09:54:46 +0100 Subject: [PATCH 057/643] Update MQTT QoS description string (#103036) Update strings.json --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 68fa39bfdc9..6197e580b1d 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -181,7 +181,7 @@ }, "qos": { "name": "QoS", - "description": "Quality of Service to use. O. At most once. 1: At least once. 2: Exactly once." + "description": "Quality of Service to use. 0: At most once. 1: At least once. 2: Exactly once." }, "retain": { "name": "Retain", From 891ad0b1be2c56f3b0c4dbc353149a19ae8cf60a Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Mon, 30 Oct 2023 20:56:50 +1300 Subject: [PATCH 058/643] Bump starlink-grpc-core to 1.1.3 (#103043) --- homeassistant/components/starlink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index c719afa968d..b8733dd2435 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/starlink", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["starlink-grpc-core==1.1.2"] + "requirements": ["starlink-grpc-core==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc5ed080b1b..f9fa0cdc2b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2488,7 +2488,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.1.2 +starlink-grpc-core==1.1.3 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0d89daaa42..2c944ecf02c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1851,7 +1851,7 @@ srpenergy==1.3.6 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.1.2 +starlink-grpc-core==1.1.3 # homeassistant.components.statsd statsd==3.2.1 From f113d9aa713b7a93cf3dc7a6b1a3e482c2072632 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Mon, 30 Oct 2023 15:57:00 +0100 Subject: [PATCH 059/643] Use correct config entry field to update when IP changes in loqed (#103051) --- homeassistant/components/loqed/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 911ccb0ff5b..1c76f480529 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import webhook from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_NAME, CONF_WEBHOOK_ID +from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -95,7 +95,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Check if already exists await self.async_set_unique_id(lock_data["bridge_mac_wifi"]) - self._abort_if_unique_id_configured({CONF_HOST: host}) + self._abort_if_unique_id_configured({"bridge_ip": host}) return await self.async_step_user() From 31d8f4b35df7d4406ed68e0a2a5c5d3dd31ca799 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 30 Oct 2023 08:08:51 -0700 Subject: [PATCH 060/643] Fix Opower not refreshing statistics when there are no forecast entities (#103058) Ensure _insert_statistics is periodically called --- homeassistant/components/opower/coordinator.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 5ce35e949af..239f23e7523 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -23,7 +23,7 @@ from homeassistant.components.recorder.statistics import ( statistics_during_period, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -58,6 +58,16 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): entry_data.get(CONF_TOTP_SECRET), ) + @callback + def _dummy_listener() -> None: + pass + + # Force the coordinator to periodically update by registering at least one listener. + # Needed when the _async_update_data below returns {} for utilities that don't provide + # forecast, which results to no sensors added, no registered listeners, and thus + # _async_update_data not periodically getting called which is needed for _insert_statistics. + self.async_add_listener(_dummy_listener) + async def _async_update_data( self, ) -> dict[str, Forecast]: @@ -71,6 +81,8 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): raise ConfigEntryAuthFailed from err forecasts: list[Forecast] = await self.api.async_get_forecast() _LOGGER.debug("Updating sensor data with: %s", forecasts) + # Because Opower provides historical usage/cost with a delay of a couple of days + # we need to insert data into statistics. await self._insert_statistics() return {forecast.account.utility_account_id: forecast for forecast in forecasts} From 3728f3da69348ac035d5b07039504145f37751dd Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:47:33 +0100 Subject: [PATCH 061/643] Update PyViCare to v2.28.1 for ViCare integration (#103064) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 418172975d8..e8bc4178073 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.25.0"] + "requirements": ["PyViCare==2.28.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f9fa0cdc2b6..dcbe0859038 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -113,7 +113,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.25.0 +PyViCare==2.28.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2c944ecf02c..4715e175534 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.25.0 +PyViCare==2.28.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From c7b702f3c2ae38a97d52be9415a9bb9e6929aed2 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 30 Oct 2023 09:57:24 -0400 Subject: [PATCH 062/643] Bump pyschlage to 2023.10.0 (#103065) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 3568692c6ca..f474f739904 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.9.1"] + "requirements": ["pyschlage==2023.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dcbe0859038..34d110f4a3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2004,7 +2004,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.9.1 +pyschlage==2023.10.0 # homeassistant.components.sensibo pysensibo==1.0.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4715e175534..1328f70970e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1511,7 +1511,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.9.1 +pyschlage==2023.10.0 # homeassistant.components.sensibo pysensibo==1.0.35 From bac39f0061bbf4c840dbb16f1d9725938daa76f9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 30 Oct 2023 19:40:27 +0100 Subject: [PATCH 063/643] Show a warning when no Withings data found (#103066) --- homeassistant/components/withings/sensor.py | 6 ++++++ tests/components/withings/test_sensor.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 1bef72c48ec..707059a2930 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -40,6 +40,7 @@ from homeassistant.util import dt as dt_util from . import WithingsData from .const import ( DOMAIN, + LOGGER, SCORE_POINTS, UOM_BEATS_PER_MINUTE, UOM_BREATHS_PER_MINUTE, @@ -787,6 +788,11 @@ async def async_setup_entry( _async_add_workout_entities ) + if not entities: + LOGGER.warning( + "No data found for Withings entry %s, sensors will be added when new data is available" + ) + async_add_entities(entities) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 0bf6b323146..5d42ace495b 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch +from aiowithings import Goals from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -341,3 +342,20 @@ async def test_workout_sensors_created_when_receive_workout_data( await hass.async_block_till_done() assert hass.states.get("sensor.henk_last_workout_type") + + +async def test_warning_if_no_entities_created( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we log a warning if no entities are created at startup.""" + withings.get_workouts_in_period.return_value = [] + withings.get_goals.return_value = Goals(None, None, None) + withings.get_measurement_in_period.return_value = [] + withings.get_sleep_summary_since.return_value = [] + withings.get_activities_since.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert "No data found for Withings entry" in caplog.text From 8d781ff0638b8d6a6c7e76cd6eeef59b7e8786b0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 30 Oct 2023 15:47:08 +0100 Subject: [PATCH 064/643] Add 2 properties to Withings diagnostics (#103067) --- homeassistant/components/withings/diagnostics.py | 2 ++ tests/components/withings/snapshots/test_diagnostics.ambr | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py index 7ed9f6ce2c9..31c9ffef569 100644 --- a/homeassistant/components/withings/diagnostics.py +++ b/homeassistant/components/withings/diagnostics.py @@ -33,4 +33,6 @@ async def async_get_config_entry_diagnostics( "webhooks_connected": withings_data.measurement_coordinator.webhooks_connected, "received_measurements": list(withings_data.measurement_coordinator.data), "received_sleep_data": withings_data.sleep_coordinator.data is not None, + "received_workout_data": withings_data.workout_coordinator.data is not None, + "received_activity_data": withings_data.activity_coordinator.data is not None, } diff --git a/tests/components/withings/snapshots/test_diagnostics.ambr b/tests/components/withings/snapshots/test_diagnostics.ambr index 3b6a5390bd6..f9b4a1d9bba 100644 --- a/tests/components/withings/snapshots/test_diagnostics.ambr +++ b/tests/components/withings/snapshots/test_diagnostics.ambr @@ -3,6 +3,7 @@ dict({ 'has_cloudhooks': True, 'has_valid_external_webhook_url': True, + 'received_activity_data': False, 'received_measurements': list([ 1, 8, @@ -26,6 +27,7 @@ 169, ]), 'received_sleep_data': True, + 'received_workout_data': True, 'webhooks_connected': True, }) # --- @@ -33,6 +35,7 @@ dict({ 'has_cloudhooks': False, 'has_valid_external_webhook_url': False, + 'received_activity_data': False, 'received_measurements': list([ 1, 8, @@ -56,6 +59,7 @@ 169, ]), 'received_sleep_data': True, + 'received_workout_data': True, 'webhooks_connected': False, }) # --- @@ -63,6 +67,7 @@ dict({ 'has_cloudhooks': False, 'has_valid_external_webhook_url': True, + 'received_activity_data': False, 'received_measurements': list([ 1, 8, @@ -86,6 +91,7 @@ 169, ]), 'received_sleep_data': True, + 'received_workout_data': True, 'webhooks_connected': True, }) # --- From a3ebfaebe7c928d9c8771b1f547ac9a61d0e3672 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Oct 2023 19:59:32 +0100 Subject: [PATCH 065/643] Bumped version to 2023.11.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index cbc21687110..4587f1d37a1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 92c11a19a12..d6070c019ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b2" +version = "2023.11.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 06f27e7e74f5a96754162d8c79b2eca92fa757b0 Mon Sep 17 00:00:00 2001 From: Paul Manzotti Date: Tue, 31 Oct 2023 07:09:03 +0000 Subject: [PATCH 066/643] Update geniushub-client to v0.7.1 (#103071) --- homeassistant/components/geniushub/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 4029023bb07..28079293821 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/geniushub", "iot_class": "local_polling", "loggers": ["geniushubclient"], - "requirements": ["geniushub-client==0.7.0"] + "requirements": ["geniushub-client==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 34d110f4a3d..51697865b8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -857,7 +857,7 @@ gassist-text==0.0.10 gcal-sync==5.0.0 # homeassistant.components.geniushub -geniushub-client==0.7.0 +geniushub-client==0.7.1 # homeassistant.components.geocaching geocachingapi==0.2.1 From 41500cbe9b8f9eb36df9664bc5030960b559cba3 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Tue, 31 Oct 2023 11:49:03 +0400 Subject: [PATCH 067/643] Code cleanup for transmission integration (#103078) --- homeassistant/components/transmission/__init__.py | 12 +----------- homeassistant/components/transmission/config_flow.py | 9 +++------ homeassistant/components/transmission/coordinator.py | 8 ++++---- homeassistant/components/transmission/strings.json | 2 -- 4 files changed, 8 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 7d019935e6c..df78c5d96aa 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -1,7 +1,6 @@ """Support for the Transmission BitTorrent client API.""" from __future__ import annotations -from datetime import timedelta from functools import partial import logging import re @@ -22,7 +21,6 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_USERNAME, Platform, ) @@ -69,7 +67,7 @@ MIGRATION_NAME_TO_KEY = { SERVICE_BASE_SCHEMA = vol.Schema( { - vol.Exclusive(CONF_ENTRY_ID, "identifier"): selector.ConfigEntrySelector(), + vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector(), } ) @@ -135,7 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.add_update_listener(async_options_updated) async def add_torrent(service: ServiceCall) -> None: """Add new torrent to download.""" @@ -244,10 +241,3 @@ async def get_api( except TransmissionError as error: _LOGGER.error(error) raise UnknownError from error - - -async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Triggered by config entry options updates.""" - coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - coordinator.update_interval = timedelta(seconds=entry.options[CONF_SCAN_INTERVAL]) - await coordinator.async_request_refresh() diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index d16981add87..a987233fef0 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -55,12 +55,9 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - for entry in self._async_current_entries(): - if ( - entry.data[CONF_HOST] == user_input[CONF_HOST] - and entry.data[CONF_PORT] == user_input[CONF_PORT] - ): - return self.async_abort(reason="already_configured") + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) try: await get_api(self.hass, user_input) diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index 9df509b9783..91597d0e43d 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -71,13 +71,13 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): data = self.api.session_stats() self.torrents = self.api.get_torrents() self._session = self.api.get_session() - - self.check_completed_torrent() - self.check_started_torrent() - self.check_removed_torrent() except transmission_rpc.TransmissionError as err: raise UpdateFailed("Unable to connect to Transmission client") from err + self.check_completed_torrent() + self.check_started_torrent() + self.check_removed_torrent() + return data def init_torrent_list(self) -> None: diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 81d94b9aac4..77ffd6a8b2a 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -30,9 +30,7 @@ "options": { "step": { "init": { - "title": "Configure options for Transmission", "data": { - "scan_interval": "Update frequency", "limit": "Limit", "order": "Order" } From 376a79eb42427423590478a63d0a30e51c2cfd40 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 30 Oct 2023 21:43:24 +0100 Subject: [PATCH 068/643] Refactor todo services and their schema (#103079) --- homeassistant/components/todo/__init__.py | 86 +++++------ homeassistant/components/todo/services.yaml | 27 ++-- homeassistant/components/todo/strings.json | 46 +++--- tests/components/todo/test_init.py | 152 +++++++++++--------- 4 files changed, 149 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 12eac858f75..968256ce3d9 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -43,14 +43,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_handle_todo_item_move) component.async_register_entity_service( - "create_item", + "add_item", { - vol.Required("summary"): vol.All(cv.string, vol.Length(min=1)), - vol.Optional("status", default=TodoItemStatus.NEEDS_ACTION): vol.In( - {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED} - ), + vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), }, - _async_create_todo_item, + _async_add_todo_item, required_features=[TodoListEntityFeature.CREATE_TODO_ITEM], ) component.async_register_entity_service( @@ -58,30 +55,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.All( cv.make_entity_service_schema( { - vol.Optional("uid"): cv.string, - vol.Optional("summary"): vol.All(cv.string, vol.Length(min=1)), + vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), + vol.Optional("rename"): vol.All(cv.string, vol.Length(min=1)), vol.Optional("status"): vol.In( {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED} ), } ), - cv.has_at_least_one_key("uid", "summary"), + cv.has_at_least_one_key("rename", "status"), ), _async_update_todo_item, required_features=[TodoListEntityFeature.UPDATE_TODO_ITEM], ) component.async_register_entity_service( - "delete_item", - vol.All( - cv.make_entity_service_schema( - { - vol.Optional("uid"): vol.All(cv.ensure_list, [cv.string]), - vol.Optional("summary"): vol.All(cv.ensure_list, [cv.string]), - } - ), - cv.has_at_least_one_key("uid", "summary"), + "remove_item", + cv.make_entity_service_schema( + { + vol.Required("item"): vol.All(cv.ensure_list, [cv.string]), + } ), - _async_delete_todo_items, + _async_remove_todo_items, required_features=[TodoListEntityFeature.DELETE_TODO_ITEM], ) @@ -114,13 +107,6 @@ class TodoItem: status: TodoItemStatus | None = None """A status or confirmation of the To-do item.""" - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> "TodoItem": - """Create a To-do Item from a dictionary parsed by schema validators.""" - return cls( - summary=obj.get("summary"), status=obj.get("status"), uid=obj.get("uid") - ) - class TodoListEntity(Entity): """An entity that represents a To-do list.""" @@ -232,39 +218,43 @@ async def websocket_handle_todo_item_move( connection.send_result(msg["id"]) -def _find_by_summary(summary: str, items: list[TodoItem] | None) -> TodoItem | None: - """Find a To-do List item by summary name.""" +def _find_by_uid_or_summary( + value: str, items: list[TodoItem] | None +) -> TodoItem | None: + """Find a To-do List item by uid or summary name.""" for item in items or (): - if item.summary == summary: + if value in (item.uid, item.summary): return item return None -async def _async_create_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: +async def _async_add_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: """Add an item to the To-do list.""" - await entity.async_create_todo_item(item=TodoItem.from_dict(call.data)) + await entity.async_create_todo_item( + item=TodoItem(summary=call.data["item"], status=TodoItemStatus.NEEDS_ACTION) + ) async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: """Update an item in the To-do list.""" - item = TodoItem.from_dict(call.data) - if not item.uid: - found = _find_by_summary(call.data["summary"], entity.todo_items) - if not found: - raise ValueError(f"Unable to find To-do item with summary '{item.summary}'") - item.uid = found.uid + item = call.data["item"] + found = _find_by_uid_or_summary(item, entity.todo_items) + if not found: + raise ValueError(f"Unable to find To-do item '{item}'") - await entity.async_update_todo_item(item=item) + update_item = TodoItem( + uid=found.uid, summary=call.data.get("rename"), status=call.data.get("status") + ) + + await entity.async_update_todo_item(item=update_item) -async def _async_delete_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: - """Delete an item in the To-do list.""" - uids = call.data.get("uid", []) - if not uids: - summaries = call.data.get("summary", []) - for summary in summaries: - item = _find_by_summary(summary, entity.todo_items) - if not item: - raise ValueError(f"Unable to find To-do item with summary '{summary}") - uids.append(item.uid) +async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: + """Remove an item in the To-do list.""" + uids = [] + for item in call.data.get("item", []): + found = _find_by_uid_or_summary(item, entity.todo_items) + if not found or not found.uid: + raise ValueError(f"Unable to find To-do item '{item}") + uids.append(found.uid) await entity.async_delete_todo_items(uids=uids) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index c31a7e88808..4d6237760ca 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -1,23 +1,15 @@ -create_item: +add_item: target: entity: domain: todo supported_features: - todo.TodoListEntityFeature.CREATE_TODO_ITEM fields: - summary: + item: required: true example: "Submit income tax return" selector: text: - status: - example: "needs_action" - selector: - select: - translation_key: status - options: - - needs_action - - completed update_item: target: entity: @@ -25,11 +17,13 @@ update_item: supported_features: - todo.TodoListEntityFeature.UPDATE_TODO_ITEM fields: - uid: + item: + required: true + example: "Submit income tax return" selector: text: - summary: - example: "Submit income tax return" + rename: + example: "Something else" selector: text: status: @@ -40,16 +34,13 @@ update_item: options: - needs_action - completed -delete_item: +remove_item: target: entity: domain: todo supported_features: - todo.TodoListEntityFeature.DELETE_TODO_ITEM fields: - uid: - selector: - object: - summary: + item: selector: object: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 623c46375f0..6ba8aaba1a5 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -6,49 +6,41 @@ } }, "services": { - "create_item": { - "name": "Create to-do list item", + "add_item": { + "name": "Add to-do list item", "description": "Add a new to-do list item.", "fields": { - "summary": { - "name": "Summary", - "description": "The short summary that represents the to-do item." - }, - "status": { - "name": "Status", - "description": "A status or confirmation of the to-do item." + "item": { + "name": "Item name", + "description": "The name that represents the to-do item." } } }, "update_item": { "name": "Update to-do list item", - "description": "Update an existing to-do list item based on either its unique ID or summary.", + "description": "Update an existing to-do list item based on its name.", "fields": { - "uid": { - "name": "To-do item unique ID", - "description": "Unique identifier for the to-do list item." + "item": { + "name": "Item name", + "description": "The name for the to-do list item." }, - "summary": { - "name": "Summary", - "description": "The short summary that represents the to-do item." + "rename": { + "name": "Rename item", + "description": "The new name of the to-do item" }, "status": { - "name": "Status", + "name": "Set status", "description": "A status or confirmation of the to-do item." } } }, - "delete_item": { - "name": "Delete a to-do list item", - "description": "Delete an existing to-do list item either by its unique ID or summary.", + "remove_item": { + "name": "Remove a to-do list item", + "description": "Remove an existing to-do list item by its name.", "fields": { - "uid": { - "name": "To-do item unique IDs", - "description": "Unique identifiers for the to-do list items." - }, - "summary": { - "name": "Summary", - "description": "The short summary that represents the to-do item." + "item": { + "name": "Item name", + "description": "The name for the to-do list items." } } } diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index f4d671ad352..3e84049efa8 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -197,28 +197,18 @@ async def test_unsupported_websocket( assert resp.get("error", {}).get("code") == "not_found" -@pytest.mark.parametrize( - ("item_data", "expected_status"), - [ - ({}, TodoItemStatus.NEEDS_ACTION), - ({"status": "needs_action"}, TodoItemStatus.NEEDS_ACTION), - ({"status": "completed"}, TodoItemStatus.COMPLETED), - ], -) -async def test_create_item_service( +async def test_add_item_service( hass: HomeAssistant, - item_data: dict[str, Any], - expected_status: TodoItemStatus, test_entity: TodoListEntity, ) -> None: - """Test creating an item in a To-do list.""" + """Test adding an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, - "create_item", - {"summary": "New item", **item_data}, + "add_item", + {"item": "New item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -229,14 +219,14 @@ async def test_create_item_service( assert item assert item.uid is None assert item.summary == "New item" - assert item.status == expected_status + assert item.status == TodoItemStatus.NEEDS_ACTION -async def test_create_item_service_raises( +async def test_add_item_service_raises( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test creating an item in a To-do list that raises an error.""" + """Test adding an item in a To-do list that raises an error.""" await create_mock_platform(hass, [test_entity]) @@ -244,8 +234,8 @@ async def test_create_item_service_raises( with pytest.raises(HomeAssistantError, match="Ooops"): await hass.services.async_call( DOMAIN, - "create_item", - {"summary": "New item", "status": "needs_action"}, + "add_item", + {"item": "New item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -255,27 +245,23 @@ async def test_create_item_service_raises( ("item_data", "expected_error"), [ ({}, "required key not provided"), - ({"status": "needs_action"}, "required key not provided"), - ( - {"summary": "", "status": "needs_action"}, - "length of value must be at least 1", - ), + ({"item": ""}, "length of value must be at least 1"), ], ) -async def test_create_item_service_invalid_input( +async def test_add_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, item_data: dict[str, Any], expected_error: str, ) -> None: - """Test invalid input to the create item service.""" + """Test invalid input to the add item service.""" await create_mock_platform(hass, [test_entity]) with pytest.raises(vol.Invalid, match=expected_error): await hass.services.async_call( DOMAIN, - "create_item", + "add_item", item_data, target={"entity_id": "todo.entity1"}, blocking=True, @@ -293,7 +279,7 @@ async def test_update_todo_item_service_by_id( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -302,7 +288,7 @@ async def test_update_todo_item_service_by_id( assert args item = args.kwargs.get("item") assert item - assert item.uid == "item-1" + assert item.uid == "1" assert item.summary == "Updated item" assert item.status == TodoItemStatus.COMPLETED @@ -318,7 +304,7 @@ async def test_update_todo_item_service_by_id_status_only( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "status": "completed"}, + {"item": "1", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -327,12 +313,12 @@ async def test_update_todo_item_service_by_id_status_only( assert args item = args.kwargs.get("item") assert item - assert item.uid == "item-1" + assert item.uid == "1" assert item.summary is None assert item.status == TodoItemStatus.COMPLETED -async def test_update_todo_item_service_by_id_summary_only( +async def test_update_todo_item_service_by_id_rename( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: @@ -343,7 +329,7 @@ async def test_update_todo_item_service_by_id_summary_only( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item"}, + {"item": "1", "rename": "Updated item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -352,7 +338,7 @@ async def test_update_todo_item_service_by_id_summary_only( assert args item = args.kwargs.get("item") assert item - assert item.uid == "item-1" + assert item.uid == "1" assert item.summary == "Updated item" assert item.status is None @@ -368,7 +354,7 @@ async def test_update_todo_item_service_raises( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -378,7 +364,7 @@ async def test_update_todo_item_service_raises( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -395,7 +381,7 @@ async def test_update_todo_item_service_by_summary( await hass.services.async_call( DOMAIN, "update_item", - {"summary": "Item #1", "status": "completed"}, + {"item": "Item #1", "rename": "Something else", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -405,10 +391,35 @@ async def test_update_todo_item_service_by_summary( item = args.kwargs.get("item") assert item assert item.uid == "1" - assert item.summary == "Item #1" + assert item.summary == "Something else" assert item.status == TodoItemStatus.COMPLETED +async def test_update_todo_item_service_by_summary_only_status( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list by summary.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "Item #1", "rename": "Something else"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "1" + assert item.summary == "Something else" + assert item.status is None + + async def test_update_todo_item_service_by_summary_not_found( hass: HomeAssistant, test_entity: TodoListEntity, @@ -421,7 +432,7 @@ async def test_update_todo_item_service_by_summary_not_found( await hass.services.async_call( DOMAIN, "update_item", - {"summary": "Item #7", "status": "completed"}, + {"item": "Item #7", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -430,10 +441,11 @@ async def test_update_todo_item_service_by_summary_not_found( @pytest.mark.parametrize( ("item_data", "expected_error"), [ - ({}, "must contain at least one of"), - ({"status": "needs_action"}, "must contain at least one of"), + ({}, r"required key not provided @ data\['item'\]"), + ({"status": "needs_action"}, r"required key not provided @ data\['item'\]"), + ({"item": "Item #1"}, "must contain at least one of"), ( - {"summary": "", "status": "needs_action"}, + {"item": "", "status": "needs_action"}, "length of value must be at least 1", ), ], @@ -458,32 +470,32 @@ async def test_update_item_service_invalid_input( ) -async def test_delete_todo_item_service_by_id( +async def test_remove_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list.""" + """Test removing an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, - "delete_item", - {"uid": ["item-1", "item-2"]}, + "remove_item", + {"item": ["1", "2"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_delete_todo_items.call_args assert args - assert args.kwargs.get("uids") == ["item-1", "item-2"] + assert args.kwargs.get("uids") == ["1", "2"] -async def test_delete_todo_item_service_raises( +async def test_remove_todo_item_service_raises( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list that raises an error.""" + """Test removing an item in a To-do list that raises an error.""" await create_mock_platform(hass, [test_entity]) @@ -491,43 +503,45 @@ async def test_delete_todo_item_service_raises( with pytest.raises(HomeAssistantError, match="Ooops"): await hass.services.async_call( DOMAIN, - "delete_item", - {"uid": ["item-1", "item-2"]}, + "remove_item", + {"item": ["1", "2"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) -async def test_delete_todo_item_service_invalid_input( +async def test_remove_todo_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test invalid input to the delete item service.""" + """Test invalid input to the remove item service.""" await create_mock_platform(hass, [test_entity]) - with pytest.raises(vol.Invalid, match="must contain at least one of"): + with pytest.raises( + vol.Invalid, match=r"required key not provided @ data\['item'\]" + ): await hass.services.async_call( DOMAIN, - "delete_item", + "remove_item", {}, target={"entity_id": "todo.entity1"}, blocking=True, ) -async def test_delete_todo_item_service_by_summary( +async def test_remove_todo_item_service_by_summary( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list by summary.""" + """Test removing an item in a To-do list by summary.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, - "delete_item", - {"summary": ["Item #1"]}, + "remove_item", + {"item": ["Item #1"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -537,19 +551,19 @@ async def test_delete_todo_item_service_by_summary( assert args.kwargs.get("uids") == ["1"] -async def test_delete_todo_item_service_by_summary_not_found( +async def test_remove_todo_item_service_by_summary_not_found( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list by summary which is not found.""" + """Test removing an item in a To-do list by summary which is not found.""" await create_mock_platform(hass, [test_entity]) with pytest.raises(ValueError, match="Unable to find"): await hass.services.async_call( DOMAIN, - "delete_item", - {"summary": ["Item #7"]}, + "remove_item", + {"item": ["Item #7"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -656,22 +670,22 @@ async def test_move_todo_item_service_invalid_input( ("service_name", "payload"), [ ( - "create_item", + "add_item", { - "summary": "New item", + "item": "New item", }, ), ( - "delete_item", + "remove_item", { - "uid": ["1"], + "item": ["1"], }, ), ( "update_item", { - "uid": "1", - "summary": "Updated item", + "item": "1", + "rename": "Updated item", }, ), ], From 67edb98e5908ac0d6e9de3c96729c3e6eca69413 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 31 Oct 2023 08:31:53 +0100 Subject: [PATCH 069/643] Fix Met Device Info (#103082) --- homeassistant/components/met/__init__.py | 12 +++++++++++ homeassistant/components/met/weather.py | 14 ++++++------- tests/components/met/test_init.py | 26 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 16bfc93f715..53764252043 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -20,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -68,6 +69,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + await cleanup_old_device(hass) + return True @@ -88,6 +91,15 @@ async def async_update_entry(hass: HomeAssistant, config_entry: ConfigEntry): await hass.config_entries.async_reload(config_entry.entry_id) +async def cleanup_old_device(hass: HomeAssistant) -> None: + """Cleanup device without proper device identifier.""" + device_reg = dr.async_get(hass) + device = device_reg.async_get_device(identifiers={(DOMAIN,)}) # type: ignore[arg-type] + if device: + _LOGGER.debug("Removing improper device %s", device.name) + device_reg.async_remove_device(device.id) + + class CannotConnect(HomeAssistantError): """Unable to connect to the web site.""" diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index def06634f42..8a5c405c1c1 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -60,7 +60,7 @@ async def async_setup_entry( if TYPE_CHECKING: assert isinstance(name, str) - entities = [MetWeather(coordinator, config_entry.data, False, name, is_metric)] + entities = [MetWeather(coordinator, config_entry, False, name, is_metric)] # Add hourly entity to legacy config entries if entity_registry.async_get_entity_id( @@ -69,9 +69,7 @@ async def async_setup_entry( _calculate_unique_id(config_entry.data, True), ): name = f"{name} hourly" - entities.append( - MetWeather(coordinator, config_entry.data, True, name, is_metric) - ) + entities.append(MetWeather(coordinator, config_entry, True, name, is_metric)) async_add_entities(entities) @@ -114,22 +112,22 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): def __init__( self, coordinator: MetDataUpdateCoordinator, - config: MappingProxyType[str, Any], + config_entry: ConfigEntry, hourly: bool, name: str, is_metric: bool, ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) - self._attr_unique_id = _calculate_unique_id(config, hourly) - self._config = config + self._attr_unique_id = _calculate_unique_id(config_entry.data, hourly) + self._config = config_entry.data self._is_metric = is_metric self._hourly = hourly self._attr_entity_registry_enabled_default = not hourly self._attr_device_info = DeviceInfo( name="Forecast", entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, # type: ignore[arg-type] + identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer="Met.no", model="Forecast", configuration_url="https://www.met.no/en", diff --git a/tests/components/met/test_init.py b/tests/components/met/test_init.py index d9085f8251f..652763947df 100644 --- a/tests/components/met/test_init.py +++ b/tests/components/met/test_init.py @@ -9,6 +9,7 @@ from homeassistant.components.met.const import ( from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import init_integration @@ -48,3 +49,28 @@ async def test_fail_default_home_entry( "Skip setting up met.no integration; No Home location has been set" in caplog.text ) + + +async def test_removing_incorrect_devices( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_weather +) -> None: + """Test we remove incorrect devices.""" + entry = await init_integration(hass) + + device_reg = dr.async_get(hass) + device_reg.async_get_or_create( + config_entry_id=entry.entry_id, + name="Forecast_legacy", + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DOMAIN,)}, + manufacturer="Met.no", + model="Forecast", + configuration_url="https://www.met.no/en", + ) + + assert await hass.config_entries.async_reload(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert not device_reg.async_get_device(identifiers={(DOMAIN,)}) + assert device_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) + assert "Removing improper device Forecast_legacy" in caplog.text From d76c16fa3a2e74cc9df9e43ce1d297589774b3e9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 Oct 2023 10:05:16 +0100 Subject: [PATCH 070/643] Update frontend to 20231030.0 (#103086) --- 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 a47ef38264e..b1eaaaf77e1 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==20231027.0"] + "requirements": ["home-assistant-frontend==20231030.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5d68cead747..cd1623c7d0d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231027.0 +home-assistant-frontend==20231030.0 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 51697865b8a..4adfc711309 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231027.0 +home-assistant-frontend==20231030.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1328f70970e..aa37475486d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231027.0 +home-assistant-frontend==20231030.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From abaeacbd6b690c227de3ebb28f65e0b60a8502ff Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 10:05:03 +0100 Subject: [PATCH 071/643] Fix restore state for light when saved attribute is None (#103096) --- .../components/light/reproduce_state.py | 18 +++---- .../components/light/test_reproduce_state.py | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 15141b6d428..f055f02ebda 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -149,31 +149,29 @@ async def _async_reproduce_state( service = SERVICE_TURN_ON for attr in ATTR_GROUP: # All attributes that are not colors - if attr in state.attributes: - service_data[attr] = state.attributes[attr] + if (attr_state := state.attributes.get(attr)) is not None: + service_data[attr] = attr_state if ( state.attributes.get(ATTR_COLOR_MODE, ColorMode.UNKNOWN) != ColorMode.UNKNOWN ): color_mode = state.attributes[ATTR_COLOR_MODE] - if color_mode_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): - if color_mode_attr.state_attr not in state.attributes: + if cm_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): + if (cm_attr_state := state.attributes.get(cm_attr.state_attr)) is None: _LOGGER.warning( "Color mode %s specified but attribute %s missing for: %s", color_mode, - color_mode_attr.state_attr, + cm_attr.state_attr, state.entity_id, ) return - service_data[color_mode_attr.parameter] = state.attributes[ - color_mode_attr.state_attr - ] + service_data[cm_attr.parameter] = cm_attr_state else: # Fall back to Choosing the first color that is specified for color_attr in COLOR_GROUP: - if color_attr in state.attributes: - service_data[color_attr] = state.attributes[color_attr] + if (color_attr_state := state.attributes.get(color_attr)) is not None: + service_data[color_attr] = color_attr_state break elif state.state == STATE_OFF: diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index f36b8180560..816bde430e7 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -22,6 +22,20 @@ VALID_RGBW_COLOR = {"rgbw_color": (255, 63, 111, 10)} VALID_RGBWW_COLOR = {"rgbww_color": (255, 63, 111, 10, 20)} VALID_XY_COLOR = {"xy_color": (0.59, 0.274)} +NONE_BRIGHTNESS = {"brightness": None} +NONE_FLASH = {"flash": None} +NONE_EFFECT = {"effect": None} +NONE_TRANSITION = {"transition": None} +NONE_COLOR_NAME = {"color_name": None} +NONE_COLOR_TEMP = {"color_temp": None} +NONE_HS_COLOR = {"hs_color": None} +NONE_KELVIN = {"kelvin": None} +NONE_PROFILE = {"profile": None} +NONE_RGB_COLOR = {"rgb_color": None} +NONE_RGBW_COLOR = {"rgbw_color": None} +NONE_RGBWW_COLOR = {"rgbww_color": None} +NONE_XY_COLOR = {"xy_color": None} + async def test_reproducing_states( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -237,3 +251,39 @@ async def test_deprecation_warning( ) assert len(turn_on_calls) == 1 assert DEPRECATION_WARNING % ["brightness_pct"] in caplog.text + + +@pytest.mark.parametrize( + "saved_state", + ( + NONE_BRIGHTNESS, + NONE_FLASH, + NONE_EFFECT, + NONE_TRANSITION, + NONE_COLOR_NAME, + NONE_COLOR_TEMP, + NONE_HS_COLOR, + NONE_KELVIN, + NONE_PROFILE, + NONE_RGB_COLOR, + NONE_RGBW_COLOR, + NONE_RGBWW_COLOR, + NONE_XY_COLOR, + ), +) +async def test_filter_none(hass: HomeAssistant, saved_state) -> None: + """Test filtering of parameters which are None.""" + hass.states.async_set("light.entity", "off", {}) + + turn_on_calls = async_mock_service(hass, "light", "turn_on") + + await async_reproduce_state(hass, [State("light.entity", "on", saved_state)]) + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "light" + assert dict(turn_on_calls[0].data) == {"entity_id": "light.entity"} + + # This should do nothing, the light is already in the desired state + hass.states.async_set("light.entity", "on", {}) + await async_reproduce_state(hass, [State("light.entity", "on", saved_state)]) + assert len(turn_on_calls) == 1 From 957998ea8dcd83942b10744c120e641718be01d2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:03:34 +0100 Subject: [PATCH 072/643] Fix google_tasks todo tests (#103098) --- tests/components/google_tasks/test_todo.py | 28 +++++++++++++--------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 5dc7f10fea0..e19ac1272cd 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -30,6 +30,12 @@ LIST_TASKS_RESPONSE = { "items": [], } +LIST_TASKS_RESPONSE_WATER = { + "items": [ + {"id": "some-task-id", "title": "Water", "status": "needsAction"}, + ], +} + @pytest.fixture def platforms() -> list[str]: @@ -198,8 +204,8 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "Soda"}, + "add_item", + {"item": "Soda"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -215,7 +221,7 @@ async def test_create_todo_list_item( [ [ LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, + LIST_TASKS_RESPONSE_WATER, EMPTY_RESPONSE, # update LIST_TASKS_RESPONSE, # refresh after update ] @@ -234,12 +240,12 @@ async def test_update_todo_list_item( state = hass.states.get("todo.my_tasks") assert state - assert state.state == "0" + assert state.state == "1" await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "some-task-id", "summary": "Soda", "status": "completed"}, + {"item": "some-task-id", "rename": "Soda", "status": "completed"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -255,7 +261,7 @@ async def test_update_todo_list_item( [ [ LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, + LIST_TASKS_RESPONSE_WATER, EMPTY_RESPONSE, # update LIST_TASKS_RESPONSE, # refresh after update ] @@ -274,12 +280,12 @@ async def test_partial_update_title( state = hass.states.get("todo.my_tasks") assert state - assert state.state == "0" + assert state.state == "1" await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "some-task-id", "summary": "Soda"}, + {"item": "some-task-id", "rename": "Soda"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -295,7 +301,7 @@ async def test_partial_update_title( [ [ LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, + LIST_TASKS_RESPONSE_WATER, EMPTY_RESPONSE, # update LIST_TASKS_RESPONSE, # refresh after update ] @@ -314,12 +320,12 @@ async def test_partial_update_status( state = hass.states.get("todo.my_tasks") assert state - assert state.state == "0" + assert state.state == "1" await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "some-task-id", "status": "needs_action"}, + {"item": "some-task-id", "status": "needs_action"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) From 26b7e94c4f19e26e809e02b5145db8a66b57b3c7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:03:54 +0100 Subject: [PATCH 073/643] Fix shopping_list todo tests (#103100) --- tests/components/shopping_list/test_todo.py | 83 ++++++++------------- 1 file changed, 31 insertions(+), 52 deletions(-) diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index ab28c6cbe6d..681ccea60ac 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -7,7 +7,6 @@ import pytest from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator @@ -115,18 +114,18 @@ async def test_get_items( assert state.state == "1" -async def test_create_item( +async def test_add_item( hass: HomeAssistant, sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test creating shopping_list item and listing it.""" + """Test adding shopping_list item and listing it.""" await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -142,38 +141,18 @@ async def test_create_item( assert state assert state.state == "1" - # Add a completed item - await hass.services.async_call( - TODO_DOMAIN, - "create_item", - {"summary": "paper", "status": "completed"}, - target={"entity_id": TEST_ENTITY}, - blocking=True, - ) - items = await ws_get_items() - assert len(items) == 2 - assert items[0]["summary"] == "soda" - assert items[0]["status"] == "needs_action" - assert items[1]["summary"] == "paper" - assert items[1]["status"] == "completed" - - state = hass.states.get(TEST_ENTITY) - assert state - assert state.state == "1" - - -async def test_delete_item( +async def test_remove_item( hass: HomeAssistant, sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting a todo item.""" + """Test removing a todo item.""" await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "soda", "status": "needs_action"}, + "add_item", + {"item": "soda"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -189,9 +168,9 @@ async def test_delete_item( await hass.services.async_call( TODO_DOMAIN, - "delete_item", + "remove_item", { - "uid": [items[0]["uid"]], + "item": [items[0]["uid"]], }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -205,20 +184,20 @@ async def test_delete_item( assert state.state == "0" -async def test_bulk_delete( +async def test_bulk_remove( hass: HomeAssistant, sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting a todo item.""" + """Test removing a todo item.""" for _i in range(0, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -234,9 +213,9 @@ async def test_bulk_delete( await hass.services.async_call( TODO_DOMAIN, - "delete_item", + "remove_item", { - "uid": uids, + "item": uids, }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -261,9 +240,9 @@ async def test_update_item( # Create new item await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -285,7 +264,7 @@ async def test_update_item( TODO_DOMAIN, "update_item", { - **item, + "item": "soda", "status": "completed", }, target={"entity_id": TEST_ENTITY}, @@ -315,9 +294,9 @@ async def test_partial_update_item( # Create new item await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -339,7 +318,7 @@ async def test_partial_update_item( TODO_DOMAIN, "update_item", { - "uid": item["uid"], + "item": item["uid"], "status": "completed", }, target={"entity_id": TEST_ENTITY}, @@ -362,8 +341,8 @@ async def test_partial_update_item( TODO_DOMAIN, "update_item", { - "uid": item["uid"], - "summary": "other summary", + "item": item["uid"], + "rename": "other summary", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -389,13 +368,13 @@ async def test_update_invalid_item( ) -> None: """Test updating a todo item that does not exist.""" - with pytest.raises(HomeAssistantError, match="was not found"): + with pytest.raises(ValueError, match="Unable to find"): await hass.services.async_call( TODO_DOMAIN, "update_item", { - "uid": "invalid-uid", - "summary": "Example task", + "item": "invalid-uid", + "rename": "Example task", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -443,9 +422,9 @@ async def test_move_item( for i in range(1, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": f"item {i}", + "item": f"item {i}", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -481,8 +460,8 @@ async def test_move_invalid_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "soda"}, + "add_item", + {"item": "soda"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) From fa0f679a9a92119434b17b625be1815763004592 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 31 Oct 2023 10:06:42 +0100 Subject: [PATCH 074/643] Fix todo.remove_item frontend (#103108) --- homeassistant/components/todo/services.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 4d6237760ca..1bdb8aca779 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -42,5 +42,6 @@ remove_item: - todo.TodoListEntityFeature.DELETE_TODO_ITEM fields: item: + required: true selector: - object: + text: From 777ffe694605ba0a9adffe7e3896dbf9bb5ecd1d Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 31 Oct 2023 10:35:51 +0100 Subject: [PATCH 075/643] Fix client id label in ViCare integration (#103111) --- homeassistant/components/vicare/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 0700d5d6f0e..056a4df7920 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -3,11 +3,11 @@ "flow_title": "{name} ({host})", "step": { "user": { - "description": "Set up ViCare integration. To generate API key go to https://developer.viessmann.com", + "description": "Set up ViCare integration. To generate client ID go to https://app.developer.viessmann.com", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", - "client_id": "[%key:common::config_flow::data::api_key%]", + "client_id": "Client ID", "heating_type": "Heating type" } } From e309bd764be068c1899eb1e94ff2cfc1a000b58e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 31 Oct 2023 11:32:17 +0100 Subject: [PATCH 076/643] Abort config flow if Google Tasks API is not enabled (#103114) Co-authored-by: Martin Hjelmare --- .../components/google_tasks/config_flow.py | 28 ++++ .../components/google_tasks/strings.json | 4 +- .../fixtures/api_not_enabled_response.json | 15 +++ .../google_tasks/test_config_flow.py | 123 +++++++++++++++++- 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 tests/components/google_tasks/fixtures/api_not_enabled_response.json diff --git a/homeassistant/components/google_tasks/config_flow.py b/homeassistant/components/google_tasks/config_flow.py index 77570f0377f..b8e5e26f42c 100644 --- a/homeassistant/components/google_tasks/config_flow.py +++ b/homeassistant/components/google_tasks/config_flow.py @@ -2,6 +2,13 @@ import logging from typing import Any +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from googleapiclient.http import HttpRequest + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, OAUTH2_SCOPES @@ -28,3 +35,24 @@ class OAuth2FlowHandler( "access_type": "offline", "prompt": "consent", } + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow.""" + try: + resource = build( + "tasks", + "v1", + credentials=Credentials(token=data[CONF_TOKEN][CONF_ACCESS_TOKEN]), + ) + cmd: HttpRequest = resource.tasklists().list() + await self.hass.async_add_executor_job(cmd.execute) + except HttpError as ex: + error = ex.reason + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": error}, + ) + except Exception as ex: # pylint: disable=broad-except + self.logger.exception("Unknown error occurred: %s", ex) + return self.async_abort(reason="unknown") + return self.async_create_entry(title=self.flow_impl.name, data=data) diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index e7dbbc2b625..f15c31f42d4 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -15,7 +15,9 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/google_tasks/fixtures/api_not_enabled_response.json b/tests/components/google_tasks/fixtures/api_not_enabled_response.json new file mode 100644 index 00000000000..75ecfddab20 --- /dev/null +++ b/tests/components/google_tasks/fixtures/api_not_enabled_response.json @@ -0,0 +1,15 @@ +{ + "error": { + "code": 403, + "message": "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "errors": [ + { + "message": "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "domain": "usageLimits", + "reason": "accessNotConfigured", + "extendedHelp": "https://console.developers.google.com" + } + ], + "status": "PERMISSION_DENIED" + } +} diff --git a/tests/components/google_tasks/test_config_flow.py b/tests/components/google_tasks/test_config_flow.py index b05e1eb108d..e92da605697 100644 --- a/tests/components/google_tasks/test_config_flow.py +++ b/tests/components/google_tasks/test_config_flow.py @@ -2,6 +2,9 @@ from unittest.mock import patch +from googleapiclient.errors import HttpError +from httplib2 import Response + from homeassistant import config_entries from homeassistant.components.google_tasks.const import ( DOMAIN, @@ -9,8 +12,11 @@ from homeassistant.components.google_tasks.const import ( OAUTH2_TOKEN, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from tests.common import load_fixture + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -59,8 +65,119 @@ async def test_full_flow( with patch( "homeassistant.components.google_tasks.async_setup_entry", return_value=True - ) as mock_setup: - await hass.config_entries.flow.async_configure(result["flow_id"]) - + ) as mock_setup, patch("homeassistant.components.google_tasks.config_flow.build"): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 + + +async def test_api_not_enabled( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check flow aborts if api is not enabled.""" + result = await hass.config_entries.flow.async_init( + "google_tasks", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/tasks" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_tasks.config_flow.build", + side_effect=HttpError( + Response({"status": "403"}), + bytes(load_fixture("google_tasks/api_not_enabled_response.json"), "utf-8"), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "access_not_configured" + assert ( + result["description_placeholders"]["message"] + == "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + ) + + +async def test_general_exception( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check flow aborts if exception happens.""" + result = await hass.config_entries.flow.async_init( + "google_tasks", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/tasks" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_tasks.config_flow.build", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" From 9ae29e243df7fdeb7e8c26ca67f41f77a769adc4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Oct 2023 13:30:10 +0100 Subject: [PATCH 077/643] Bumped version to 2023.11.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4587f1d37a1..a21d7a8f647 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index d6070c019ae..b479c69aa54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b3" +version = "2023.11.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 19479b2a68654fb51c23059ac9cd0b30cde2f96e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:38:58 +0100 Subject: [PATCH 078/643] Fix local_todo todo tests (#103099) --- tests/components/local_todo/test_todo.py | 46 ++++++++++++------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 8a7e38c9773..39e9264d45a 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -79,13 +79,13 @@ async def ws_move_item( return move -async def test_create_item( +async def test_add_item( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test creating a todo item.""" + """Test adding a todo item.""" state = hass.states.get(TEST_ENTITY) assert state @@ -93,8 +93,8 @@ async def test_create_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "replace batteries"}, + "add_item", + {"item": "replace batteries"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -110,16 +110,16 @@ async def test_create_item( assert state.state == "1" -async def test_delete_item( +async def test_remove_item( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting a todo item.""" + """Test removing a todo item.""" await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "replace batteries"}, + "add_item", + {"item": "replace batteries"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -136,8 +136,8 @@ async def test_delete_item( await hass.services.async_call( TODO_DOMAIN, - "delete_item", - {"uid": [items[0]["uid"]]}, + "remove_item", + {"item": [items[0]["uid"]]}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -150,17 +150,17 @@ async def test_delete_item( assert state.state == "0" -async def test_bulk_delete( +async def test_bulk_remove( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting multiple todo items.""" + """Test removing multiple todo items.""" for i in range(0, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": f"soda #{i}"}, + "add_item", + {"item": f"soda #{i}"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -175,8 +175,8 @@ async def test_bulk_delete( await hass.services.async_call( TODO_DOMAIN, - "delete_item", - {"uid": uids}, + "remove_item", + {"item": uids}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -199,8 +199,8 @@ async def test_update_item( # Create new item await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "soda"}, + "add_item", + {"item": "soda"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -220,7 +220,7 @@ async def test_update_item( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": item["uid"], "status": "completed"}, + {"item": item["uid"], "status": "completed"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -276,8 +276,8 @@ async def test_move_item( for i in range(1, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": f"item {i}"}, + "add_item", + {"item": f"item {i}"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -334,8 +334,8 @@ async def test_move_item_previous_unknown( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "item 1"}, + "add_item", + {"item": "item 1"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) From a48e63aa28582f24d1a0948953361ce5c8cff0c6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:38:18 +0100 Subject: [PATCH 079/643] Fix todoist todo tests (#103101) --- tests/components/todoist/test_todo.py | 46 +++++++-------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index bbfaf6c493b..a14f362ea5b 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -56,12 +56,12 @@ async def test_todo_item_state( @pytest.mark.parametrize(("tasks"), [[]]) -async def test_create_todo_list_item( +async def test_add_todo_list_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, ) -> None: - """Test for creating a To-do Item.""" + """Test for adding a To-do Item.""" state = hass.states.get("todo.name") assert state @@ -75,8 +75,8 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "Soda"}, + "add_item", + {"item": "Soda"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -92,30 +92,6 @@ async def test_create_todo_list_item( assert state.state == "1" -@pytest.mark.parametrize(("tasks"), [[]]) -async def test_create_completed_item_unsupported( - hass: HomeAssistant, - setup_integration: None, - api: AsyncMock, -) -> None: - """Test for creating a To-do Item that is already completed.""" - - state = hass.states.get("todo.name") - assert state - assert state.state == "0" - - api.add_task = AsyncMock() - - with pytest.raises(ValueError, match="Only active tasks"): - await hass.services.async_call( - TODO_DOMAIN, - "create_item", - {"summary": "Soda", "status": "completed"}, - target={"entity_id": "todo.name"}, - blocking=True, - ) - - @pytest.mark.parametrize( ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] ) @@ -141,7 +117,7 @@ async def test_update_todo_item_status( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "task-id-1", "status": "completed"}, + {"item": "task-id-1", "status": "completed"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -164,7 +140,7 @@ async def test_update_todo_item_status( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "task-id-1", "status": "needs_action"}, + {"item": "task-id-1", "status": "needs_action"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -203,7 +179,7 @@ async def test_update_todo_item_summary( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "task-id-1", "summary": "Milk"}, + {"item": "task-id-1", "rename": "Milk"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -223,12 +199,12 @@ async def test_update_todo_item_summary( ] ], ) -async def test_delete_todo_item( +async def test_remove_todo_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, ) -> None: - """Test for deleting a To-do Item.""" + """Test for removing a To-do Item.""" state = hass.states.get("todo.name") assert state @@ -240,8 +216,8 @@ async def test_delete_todo_item( await hass.services.async_call( TODO_DOMAIN, - "delete_item", - {"uid": ["task-id-1", "task-id-2"]}, + "remove_item", + {"item": ["task-id-1", "task-id-2"]}, target={"entity_id": "todo.name"}, blocking=True, ) From 040ecb74e0a9d07211433b67973afee71b0882c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 07:55:03 +0100 Subject: [PATCH 080/643] Add todo to core files (#103102) --- .core_files.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.core_files.yaml b/.core_files.yaml index b3e854de04b..f5ffdee9142 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -45,6 +45,7 @@ base_platforms: &base_platforms - homeassistant/components/switch/** - homeassistant/components/text/** - homeassistant/components/time/** + - homeassistant/components/todo/** - homeassistant/components/tts/** - homeassistant/components/update/** - homeassistant/components/vacuum/** From 09ed6e9f9b57ece487315a269971074f2d0f5e97 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 31 Oct 2023 13:42:52 +0100 Subject: [PATCH 081/643] Handle exception introduced with recent PyViCare update (#103110) --- .../components/vicare/config_flow.py | 7 +++++-- tests/components/vicare/test_config_flow.py | 21 ++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index a0feb8f38ea..5b2d3afa427 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -4,7 +4,10 @@ from __future__ import annotations import logging from typing import Any -from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +from PyViCare.PyViCareUtils import ( + PyViCareInvalidConfigurationError, + PyViCareInvalidCredentialsError, +) import voluptuous as vol from homeassistant import config_entries @@ -53,7 +56,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job( vicare_login, self.hass, user_input ) - except PyViCareInvalidCredentialsError: + except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError): errors["base"] = "invalid_auth" else: return self.async_create_entry(title=VICARE_NAME, data=user_input) diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 0774848ef11..7f70c13f0b0 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -2,7 +2,10 @@ from unittest.mock import AsyncMock, patch import pytest -from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +from PyViCare.PyViCareUtils import ( + PyViCareInvalidConfigurationError, + PyViCareInvalidCredentialsError, +) from syrupy.assertion import SnapshotAssertion from homeassistant.components import dhcp @@ -43,6 +46,22 @@ async def test_user_create_entry( assert result["step_id"] == "user" assert result["errors"] == {} + # test PyViCareInvalidConfigurationError + with patch( + f"{MODULE}.config_flow.vicare_login", + side_effect=PyViCareInvalidConfigurationError( + {"error": "foo", "error_description": "bar"} + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + # test PyViCareInvalidCredentialsError with patch( f"{MODULE}.config_flow.vicare_login", From bfae1468d6fdac1b709dea87493cfca37e67ba2e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 31 Oct 2023 15:15:20 +0100 Subject: [PATCH 082/643] Bump reolink-aio to 0.7.12 (#103120) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 9d9d8d59e88..1c1d8dd96b1 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.7.11"] + "requirements": ["reolink-aio==0.7.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4adfc711309..dff43a67450 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2319,7 +2319,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.11 +reolink-aio==0.7.12 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa37475486d..6799e4782c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1730,7 +1730,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.11 +reolink-aio==0.7.12 # homeassistant.components.rflink rflink==0.0.65 From e097dc02dd2fb12b958fbc2c149a6ca9edf4d134 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 31 Oct 2023 19:25:25 +0100 Subject: [PATCH 083/643] Don't try to load resources in safe mode (#103122) --- homeassistant/components/lovelace/websocket.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index c9b7cb10386..b756c2765e1 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -62,6 +62,7 @@ async def websocket_lovelace_resources( if hass.config.safe_mode: connection.send_result(msg["id"], []) + return if not resources.loaded: await resources.async_load() From 8992d15ffc133d97988615d8f17c9bb2976bf546 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 12:56:15 -0500 Subject: [PATCH 084/643] Bump aiohomekit to 3.0.9 (#103123) --- homeassistant/components/homekit_controller/connection.py | 4 +++- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 1d0eb9cdd83..ef806cb52bc 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -884,7 +884,9 @@ class HKDevice: self._config_changed_callbacks.add(callback_) return partial(self._remove_config_changed_callback, callback_) - async def get_characteristics(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + async def get_characteristics( + self, *args: Any, **kwargs: Any + ) -> dict[tuple[int, int], dict[str, Any]]: """Read latest state from homekit accessory.""" return await self.pairing.get_characteristics(*args, **kwargs) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index ff918396640..91fd199e17c 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.8"], + "requirements": ["aiohomekit==3.0.9"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index dff43a67450..751b7af1598 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -255,7 +255,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.8 +aiohomekit==3.0.9 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6799e4782c3..065a0a44d73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.8 +aiohomekit==3.0.9 # homeassistant.components.emulated_hue # homeassistant.components.http From f0a06efa1f90892b994d597de400a76781129e0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 12:38:05 -0500 Subject: [PATCH 085/643] Fix race in starting reauth flows (#103130) --- homeassistant/config_entries.py | 31 +++++++++++++++++++++----- tests/components/smarttub/test_init.py | 1 + tests/test_config_entries.py | 14 ++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 02a9dd9dade..2b8f1ec4065 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -223,6 +223,7 @@ class ConfigEntry: "_async_cancel_retry_setup", "_on_unload", "reload_lock", + "_reauth_lock", "_tasks", "_background_tasks", "_integration_for_domain", @@ -321,6 +322,8 @@ class ConfigEntry: # Reload lock to prevent conflicting reloads self.reload_lock = asyncio.Lock() + # Reauth lock to prevent concurrent reauth flows + self._reauth_lock = asyncio.Lock() self._tasks: set[asyncio.Future[Any]] = set() self._background_tasks: set[asyncio.Future[Any]] = set() @@ -727,12 +730,28 @@ class ConfigEntry: data: dict[str, Any] | None = None, ) -> None: """Start a reauth flow.""" + # We will check this again in the task when we hold the lock, + # but we also check it now to try to avoid creating the task. if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): # Reauth flow already in progress for this entry return - hass.async_create_task( - hass.config_entries.flow.async_init( + self._async_init_reauth(hass, context, data), + f"config entry reauth {self.title} {self.domain} {self.entry_id}", + ) + + async def _async_init_reauth( + self, + hass: HomeAssistant, + context: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + ) -> None: + """Start a reauth flow.""" + async with self._reauth_lock: + if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): + # Reauth flow already in progress for this entry + return + await hass.config_entries.flow.async_init( self.domain, context={ "source": SOURCE_REAUTH, @@ -742,9 +761,7 @@ class ConfigEntry: } | (context or {}), data=self.data | (data or {}), - ), - f"config entry reauth {self.title} {self.domain} {self.entry_id}", - ) + ) @callback def async_get_active_flows( @@ -754,7 +771,9 @@ class ConfigEntry: return ( flow for flow in hass.config_entries.flow.async_progress_by_handler( - self.domain, match_context={"entry_id": self.entry_id} + self.domain, + match_context={"entry_id": self.entry_id}, + include_uninitialized=True, ) if flow["context"].get("source") in sources ) diff --git a/tests/components/smarttub/test_init.py b/tests/components/smarttub/test_init.py index 0e88f3ed7c7..929ad687e11 100644 --- a/tests/components/smarttub/test_init.py +++ b/tests/components/smarttub/test_init.py @@ -42,6 +42,7 @@ async def test_setup_auth_failed( config_entry.add_to_hass(hass) with patch.object(hass.config_entries.flow, "async_init") as mock_flow_init: await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_ERROR mock_flow_init.assert_called_with( DOMAIN, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index d17c724cb2a..eb771b7e6a6 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3791,6 +3791,20 @@ async def test_reauth(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress()) == 2 + # Abort all existing flows + for flow in hass.config_entries.flow.async_progress(): + hass.config_entries.flow.async_abort(flow["flow_id"]) + await hass.async_block_till_done() + + # Check that we can't start duplicate reauth flows + # without blocking between flows + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 1 + async def test_get_active_flows(hass: HomeAssistant) -> None: """Test the async_get_active_flows helper.""" From 9910f9e0aedd34613a789b11ef65017816005312 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Oct 2023 19:43:21 +0100 Subject: [PATCH 086/643] Bumped version to 2023.11.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a21d7a8f647..8022275b824 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index b479c69aa54..61c18f5d4ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b4" +version = "2023.11.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From c5f21fefbe637e145e552281648a082ed1bb1537 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 1 Nov 2023 01:54:51 +0100 Subject: [PATCH 087/643] Bump python-kasa to 0.5.4 for tplink (#103038) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tplink/test_init.py | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index d13adb8ec47..e0ac41bdec6 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -169,5 +169,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.5.3"] + "requirements": ["python-kasa[speedups]==0.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 751b7af1598..f664cb7e7df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2141,7 +2141,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.3 +python-kasa[speedups]==0.5.4 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 065a0a44d73..c8c884e92b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.3 +python-kasa[speedups]==0.5.4 # homeassistant.components.matter python-matter-server==4.0.0 diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 4206c0de6ad..c40560d2a89 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -29,7 +29,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_configuring_tplink_causes_discovery(hass: HomeAssistant) -> None: """Test that specifying empty config does discovery.""" - with patch("homeassistant.components.tplink.Discover.discover") as discover: + with patch("homeassistant.components.tplink.Discover.discover") as discover, patch( + "homeassistant.components.tplink.Discover.discover_single" + ): discover.return_value = {MagicMock(): MagicMock()} await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() From ea2d2ba7b7c8c2df11d27d19e560dc091f75a387 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 31 Oct 2023 19:48:33 -0700 Subject: [PATCH 088/643] Improve fitbit oauth token error handling in config flow (#103131) * Improve fitbit oauth token error handling in config flow * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Update tests with updated error reason --------- Co-authored-by: Martin Hjelmare --- .../fitbit/application_credentials.py | 13 +++-- .../components/fitbit/config_flow.py | 15 ++++++ homeassistant/components/fitbit/strings.json | 5 +- tests/components/fitbit/test_config_flow.py | 53 ++++++++++++++++++- 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index e66b9ca9014..caf0384eca2 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -59,13 +59,16 @@ class FitbitOAuth2Implementation(AuthImplementation): resp = await session.post(self.token_url, data=data, headers=self._headers) resp.raise_for_status() except aiohttp.ClientResponseError as err: - error_body = await resp.text() - _LOGGER.debug("Client response error body: %s", error_body) + if _LOGGER.isEnabledFor(logging.DEBUG): + error_body = await resp.text() if not session.closed else "" + _LOGGER.debug( + "Client response error status=%s, body=%s", err.status, error_body + ) if err.status == HTTPStatus.UNAUTHORIZED: - raise FitbitAuthException from err - raise FitbitApiException from err + raise FitbitAuthException(f"Unauthorized error: {err}") from err + raise FitbitApiException(f"Server error response: {err}") from err except aiohttp.ClientError as err: - raise FitbitApiException from err + raise FitbitApiException(f"Client connection error: {err}") from err return cast(dict, await resp.json()) @property diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index ee2340e7587..dd7e79e2c65 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -53,6 +53,21 @@ class OAuth2FlowHandler( return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + async def async_step_creation( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Create config entry from external data with Fitbit specific error handling.""" + try: + return await super().async_step_creation() + except FitbitAuthException as err: + _LOGGER.error( + "Failed to authenticate when creating Fitbit credentials: %s", err + ) + return self.async_abort(reason="invalid_auth") + except FitbitApiException as err: + _LOGGER.error("Failed to create Fitbit credentials: %s", err) + return self.async_abort(reason="cannot_connect") + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow, or update existing entry.""" diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 2d74408a73f..889b56f1bbd 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -16,9 +16,10 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", "wrong_account": "The user credentials provided do not match this Fitbit account." diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index cf2d5d17f22..d51379c9adc 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -88,6 +88,57 @@ async def test_full_flow( } +@pytest.mark.parametrize( + ("status_code", "error_reason"), + [ + (HTTPStatus.UNAUTHORIZED, "invalid_auth"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), + ], +) +async def test_token_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, + status_code: HTTPStatus, + error_reason: str, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + status=status_code, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == error_reason + + @pytest.mark.parametrize( ("http_status", "json", "error_reason"), [ @@ -460,7 +511,7 @@ async def test_reauth_flow( "refresh_token": "updated-refresh-token", "access_token": "updated-access-token", "type": "Bearer", - "expires_in": 60, + "expires_in": "60", }, ) From bcea021c14422ce0dbcf4db8af5f403529f19137 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 16:29:22 -0500 Subject: [PATCH 089/643] Allow non-admins to subscribe to the issue registry updated event (#103145) --- homeassistant/auth/permissions/events.py | 2 ++ homeassistant/components/websocket_api/commands.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/auth/permissions/events.py b/homeassistant/auth/permissions/events.py index d50da96a39f..aec23331664 100644 --- a/homeassistant/auth/permissions/events.py +++ b/homeassistant/auth/permissions/events.py @@ -19,6 +19,7 @@ from homeassistant.const import ( from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED # These are events that do not contain any sensitive data # Except for state_changed, which is handled accordingly. @@ -28,6 +29,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[str]] = { EVENT_CORE_CONFIG_UPDATE, EVENT_DEVICE_REGISTRY_UPDATED, EVENT_ENTITY_REGISTRY_UPDATED, + EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, EVENT_LOVELACE_UPDATED, EVENT_PANELS_UPDATED, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a29bee86116..2dfa48c28fe 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -57,6 +57,8 @@ from .messages import construct_event_message, construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" +_LOGGER = logging.getLogger(__name__) + @callback def async_register_commands( @@ -132,7 +134,12 @@ def handle_subscribe_events( event_type = msg["event_type"] if event_type not in SUBSCRIBE_ALLOWLIST and not connection.user.is_admin: - raise Unauthorized + _LOGGER.error( + "Refusing to allow %s to subscribe to event %s", + connection.user.name, + event_type, + ) + raise Unauthorized(user_id=connection.user.id) if event_type == EVENT_STATE_CHANGED: forward_events = callback( From aa5ea5ebc33e52dec5c666a19b42384a722ebad8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 1 Nov 2023 09:25:56 +0100 Subject: [PATCH 090/643] Fix mqtt is not reloading without yaml config (#103159) --- homeassistant/components/mqtt/__init__.py | 2 +- tests/components/mqtt/test_init.py | 36 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index ac229cb677f..be283271dee 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -233,7 +233,7 @@ async def async_check_config_schema( ) -> None: """Validate manually configured MQTT items.""" mqtt_data = get_mqtt_data(hass) - mqtt_config: list[dict[str, list[ConfigType]]] = config_yaml[DOMAIN] + mqtt_config: list[dict[str, list[ConfigType]]] = config_yaml.get(DOMAIN, {}) for mqtt_config_item in mqtt_config: for domain, config_items in mqtt_config_item.items(): schema = mqtt_data.reload_schema[domain] diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index b071252ea64..2aa8de388b1 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3974,3 +3974,39 @@ async def test_reload_with_invalid_config( # Test nothing changed as loading the config failed assert hass.states.get("sensor.test") is not None + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": [ + { + "sensor": { + "name": "test", + "state_topic": "test-topic", + } + }, + ] + } + ], +) +async def test_reload_with_empty_config( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reloading yaml config fails.""" + await mqtt_mock_entry() + assert hass.states.get("sensor.test") is not None + + # Reload with an empty config and assert again + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test") is None From cfa2f2ce6189e9c54d4fb83d5a572478cd46ee3b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Nov 2023 11:05:17 +0100 Subject: [PATCH 091/643] Update frontend to 20231030.1 (#103163) --- 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 b1eaaaf77e1..6fffc0e8acd 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==20231030.0"] + "requirements": ["home-assistant-frontend==20231030.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd1623c7d0d..a70bcf4524a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231030.0 +home-assistant-frontend==20231030.1 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f664cb7e7df..77633056811 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231030.0 +home-assistant-frontend==20231030.1 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8c884e92b2..666c3ea4dc6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231030.0 +home-assistant-frontend==20231030.1 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From 18acec32b862def367c6de2d2807c5d186cc5662 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Nov 2023 11:22:25 +0100 Subject: [PATCH 092/643] Bumped version to 2023.11.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8022275b824..1ec9532e11f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 61c18f5d4ba..aa2a3c66b0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b5" +version = "2023.11.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 4ce859b4e4ae68f21af51a21c194a5f6a36cd554 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Nov 2023 11:24:41 +0100 Subject: [PATCH 093/643] Bumped version to 2023.11.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1ec9532e11f..28241ef15f4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index aa2a3c66b0e..7efa6915a46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b6" +version = "2023.11.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From acd98e9b407d875ee28f723652f75604d0aed00e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 1 Nov 2023 12:37:59 +0100 Subject: [PATCH 094/643] Bump python-holidays to 0.35 (#103092) --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 698ef17902f..1c9a533d998 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.28"] + "requirements": ["holidays==0.35"] } diff --git a/requirements_all.txt b/requirements_all.txt index 77633056811..17de99f535a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1004,7 +1004,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.28 +holidays==0.35 # homeassistant.components.frontend home-assistant-frontend==20231030.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 666c3ea4dc6..3c23ab27a0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -793,7 +793,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.28 +holidays==0.35 # homeassistant.components.frontend home-assistant-frontend==20231030.1 From 0c8074bab44bee9b43123f888273246f999c63fc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 Nov 2023 13:25:33 +0100 Subject: [PATCH 095/643] Bump aiowaqi to 3.0.0 (#103166) --- homeassistant/components/waqi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index 1cac5be375b..f5731da2a7e 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["aiowaqi"], - "requirements": ["aiowaqi==2.1.0"] + "requirements": ["aiowaqi==3.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 17de99f535a..7545800d5dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -378,7 +378,7 @@ aiovlc==0.1.0 aiovodafone==0.4.2 # homeassistant.components.waqi -aiowaqi==2.1.0 +aiowaqi==3.0.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c23ab27a0d..6ed2583ec3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aiovlc==0.1.0 aiovodafone==0.4.2 # homeassistant.components.waqi -aiowaqi==2.1.0 +aiowaqi==3.0.0 # homeassistant.components.watttime aiowatttime==0.1.1 From 355b51d4c8e700d5aadd6800f0cafdc0c53b2af8 Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 1 Nov 2023 10:41:41 -0400 Subject: [PATCH 096/643] Catch unexpected response in Honeywell (#103169) catch unexpected response --- homeassistant/components/honeywell/climate.py | 10 +++++++ tests/components/honeywell/test_climate.py | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index ab23c878c15..e9af4b2fd95 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -353,6 +353,11 @@ class HoneywellUSThermostat(ClimateEntity): if mode == "heat": await self._device.set_setpoint_heat(temperature) + except UnexpectedResponse as err: + raise HomeAssistantError( + "Honeywell set temperature failed: Invalid Response" + ) from err + except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) raise ValueError( @@ -369,6 +374,11 @@ class HoneywellUSThermostat(ClimateEntity): if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): await self._device.set_setpoint_heat(temperature) + except UnexpectedResponse as err: + raise HomeAssistantError( + "Honeywell set temperature failed: Invalid Response" + ) from err + except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) raise ValueError( diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 53cb70475c9..45ce862dba8 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -358,7 +358,24 @@ async def test_service_calls_off_mode( device.set_setpoint_heat.assert_called_with(77) assert "Invalid temperature" in caplog.text + device.set_setpoint_heat.reset_mock() + device.set_setpoint_heat.side_effect = aiosomecomfort.UnexpectedResponse caplog.clear() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) + device.set_setpoint_cool.assert_called_with(95) + device.set_setpoint_heat.assert_called_with(77) + reset_mock(device) await hass.services.async_call( CLIMATE_DOMAIN, @@ -702,6 +719,17 @@ async def test_service_calls_heat_mode( device.set_hold_heat.reset_mock() assert "Invalid temperature" in caplog.text + device.set_hold_heat.side_effect = aiosomecomfort.UnexpectedResponse + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) + device.set_hold_heat.assert_called_once_with(datetime.time(2, 30), 59) + device.set_hold_heat.reset_mock() + caplog.clear() await hass.services.async_call( CLIMATE_DOMAIN, From 5b4df0f7ffe267fed732204a04a6e3fb8b29c307 Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Wed, 1 Nov 2023 20:15:18 +0100 Subject: [PATCH 097/643] Fix roomba translation key mismatch (#103191) --- homeassistant/components/roomba/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 3b2b34af67b..7d103111301 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -106,7 +106,7 @@ SENSORS: list[RoombaSensorEntityDescription] = [ ), RoombaSensorEntityDescription( key="scrubs_count", - translation_key="scrubs", + translation_key="scrubs_count", icon="mdi:counter", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="Scrubs", From 2be229c5b5d3b073ffe331260a85dc68e5717ab1 Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Wed, 1 Nov 2023 21:12:57 +0100 Subject: [PATCH 098/643] Fix roomba error if battery stats are not available (#103196) --- homeassistant/components/roomba/irobot_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index ffa4e2d8292..13e78ced379 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -119,7 +119,7 @@ class IRobotEntity(Entity): @property def battery_stats(self): """Return the battery stats.""" - return self.vacuum_state.get("bbchg3") + return self.vacuum_state.get("bbchg3", {}) @property def _robot_state(self): From 239fa04d0292775e7fc1557cc24d593887e63761 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 2 Nov 2023 10:57:00 +0100 Subject: [PATCH 099/643] Fix mqtt config validation error handling (#103210) * Fix MQTT config check * Fix handling invalid enity_category for sensors * Improve docstr * Update comment * Use correct util for yaml dump --- .../components/mqtt/binary_sensor.py | 10 ++++- homeassistant/components/mqtt/climate.py | 6 +-- homeassistant/components/mqtt/fan.py | 6 +-- homeassistant/components/mqtt/humidifier.py | 6 +-- homeassistant/components/mqtt/mixins.py | 19 ++++++++-- homeassistant/components/mqtt/sensor.py | 4 +- homeassistant/components/mqtt/text.py | 4 +- .../mqtt/test_alarm_control_panel.py | 2 +- tests/components/mqtt/test_climate.py | 2 +- tests/components/mqtt/test_fan.py | 4 +- tests/components/mqtt/test_init.py | 38 ++++++++++++++++++- tests/components/mqtt/test_text.py | 4 +- 12 files changed, 80 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 7ab2e9ebf90..a89fb8a22fc 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -42,6 +42,7 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, + validate_sensor_entity_category, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -55,7 +56,7 @@ DEFAULT_PAYLOAD_ON = "ON" DEFAULT_FORCE_UPDATE = False CONF_EXPIRE_AFTER = "expire_after" -PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( +_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, @@ -67,7 +68,12 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) +DISCOVERY_SCHEMA = vol.All( + validate_sensor_entity_category, + _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), +) + +PLATFORM_SCHEMA_MODERN = vol.All(validate_sensor_entity_category, _PLATFORM_SCHEMA_BASE) async def async_setup_entry( diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index dae768a1359..358fa6eb675 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -232,16 +232,16 @@ TOPIC_KEYS = ( def valid_preset_mode_configuration(config: ConfigType) -> ConfigType: """Validate that the preset mode reset payload is not one of the preset modes.""" if PRESET_NONE in config[CONF_PRESET_MODES_LIST]: - raise ValueError("preset_modes must not include preset mode 'none'") + raise vol.Invalid("preset_modes must not include preset mode 'none'") return config def valid_humidity_range_configuration(config: ConfigType) -> ConfigType: """Validate a target_humidity range configuration, throws otherwise.""" if config[CONF_HUMIDITY_MIN] >= config[CONF_HUMIDITY_MAX]: - raise ValueError("target_humidity_max must be > target_humidity_min") + raise vol.Invalid("target_humidity_max must be > target_humidity_min") if config[CONF_HUMIDITY_MAX] > 100: - raise ValueError("max_humidity must be <= 100") + raise vol.Invalid("max_humidity must be <= 100") return config diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 02192676784..0e9e7d708e9 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -116,16 +116,16 @@ _LOGGER = logging.getLogger(__name__) def valid_speed_range_configuration(config: ConfigType) -> ConfigType: """Validate that the fan speed_range configuration is valid, throws if it isn't.""" if config[CONF_SPEED_RANGE_MIN] == 0: - raise ValueError("speed_range_min must be > 0") + raise vol.Invalid("speed_range_min must be > 0") if config[CONF_SPEED_RANGE_MIN] >= config[CONF_SPEED_RANGE_MAX]: - raise ValueError("speed_range_max must be > speed_range_min") + raise vol.Invalid("speed_range_max must be > speed_range_min") return config def valid_preset_mode_configuration(config: ConfigType) -> ConfigType: """Validate that the preset mode reset payload is not one of the preset modes.""" if config[CONF_PAYLOAD_RESET_PRESET_MODE] in config[CONF_PRESET_MODES_LIST]: - raise ValueError("preset_modes must not contain payload_reset_preset_mode") + raise vol.Invalid("preset_modes must not contain payload_reset_preset_mode") return config diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 77a74b15197..75a74a0dcaa 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -102,7 +102,7 @@ _LOGGER = logging.getLogger(__name__) def valid_mode_configuration(config: ConfigType) -> ConfigType: """Validate that the mode reset payload is not one of the available modes.""" if config[CONF_PAYLOAD_RESET_MODE] in config[CONF_AVAILABLE_MODES_LIST]: - raise ValueError("modes must not contain payload_reset_mode") + raise vol.Invalid("modes must not contain payload_reset_mode") return config @@ -113,9 +113,9 @@ def valid_humidity_range_configuration(config: ConfigType) -> ConfigType: throws if it isn't. """ if config[CONF_TARGET_HUMIDITY_MIN] >= config[CONF_TARGET_HUMIDITY_MAX]: - raise ValueError("target_humidity_max must be > target_humidity_min") + raise vol.Invalid("target_humidity_max must be > target_humidity_min") if config[CONF_TARGET_HUMIDITY_MAX] > 100: - raise ValueError("max_humidity must be <= 100") + raise vol.Invalid("max_humidity must be <= 100") return config diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 908e3c768b8..91a5511001b 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -9,7 +9,6 @@ import logging from typing import TYPE_CHECKING, Any, Protocol, cast, final import voluptuous as vol -import yaml from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,6 +27,7 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, + EntityCategory, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import ( @@ -63,6 +63,7 @@ from homeassistant.helpers.typing import ( UndefinedType, ) from homeassistant.util.json import json_loads +from homeassistant.util.yaml import dump as yaml_dump from . import debug_info, subscription from .client import async_publish @@ -207,6 +208,16 @@ def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType ) +def validate_sensor_entity_category(config: ConfigType) -> ConfigType: + """Check the sensor's entity category is not set to `config` which is invalid for sensors.""" + if ( + CONF_ENTITY_CATEGORY in config + and config[CONF_ENTITY_CATEGORY] == EntityCategory.CONFIG + ): + raise vol.Invalid("Entity category `config` is invalid") + return config + + MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( cv.deprecated(CONF_DEPRECATED_VIA_HUB, CONF_VIA_DEVICE), vol.Schema( @@ -404,8 +415,8 @@ async def async_setup_entity_entry_helper( error = str(ex) config_file = getattr(yaml_config, "__config_file__", "?") line = getattr(yaml_config, "__line__", "?") - issue_id = hex(hash(frozenset(yaml_config.items()))) - yaml_config_str = yaml.dump(dict(yaml_config)) + issue_id = hex(hash(frozenset(yaml_config))) + yaml_config_str = yaml_dump(yaml_config) learn_more_url = ( f"https://www.home-assistant.io/integrations/{domain}.mqtt/" ) @@ -427,7 +438,7 @@ async def async_setup_entity_entry_helper( translation_key="invalid_platform_config", ) _LOGGER.error( - "%s for manual configured MQTT %s item, in %s, line %s Got %s", + "%s for manually configured MQTT %s item, in %s, line %s Got %s", error, domain, config_file, diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 93151c51542..e1c7ba64aba 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -44,6 +44,7 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, + validate_sensor_entity_category, write_state_on_attr_change, ) from .models import ( @@ -70,7 +71,6 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False - _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), @@ -88,6 +88,7 @@ PLATFORM_SCHEMA_MODERN = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), + validate_sensor_entity_category, _PLATFORM_SCHEMA_BASE, ) @@ -95,6 +96,7 @@ DISCOVERY_SCHEMA = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), + validate_sensor_entity_category, _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), ) diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index f6aeac3be7c..da93a6b619e 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -71,9 +71,9 @@ MQTT_TEXT_ATTRIBUTES_BLOCKED = frozenset( def valid_text_size_configuration(config: ConfigType) -> ConfigType: """Validate that the text length configuration is valid, throws if it isn't.""" if config[CONF_MIN] >= config[CONF_MAX]: - raise ValueError("text length min must be >= max") + raise vol.Invalid("text length min must be >= max") if config[CONF_MAX] > MAX_LENGTH_STATE_STATE: - raise ValueError(f"max text length must be <= {MAX_LENGTH_STATE_STATE}") + raise vol.Invalid(f"max text length must be <= {MAX_LENGTH_STATE_STATE}") return config diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 0d5c9ee2e8d..40049431edb 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -1297,7 +1297,7 @@ async def test_reload_after_invalid_config( assert hass.states.get("alarm_control_panel.test") is None assert ( "extra keys not allowed @ data['invalid_topic'] for " - "manual configured MQTT alarm_control_panel item, " + "manually configured MQTT alarm_control_panel item, " "in ?, line ? Got {'name': 'test', 'invalid_topic': 'test-topic'}" in caplog.text ) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 89eaf87fb3a..6d6c7475366 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -139,7 +139,7 @@ async def test_preset_none_in_preset_modes( ) -> None: """Test the preset mode payload reset configuration.""" assert await mqtt_mock_entry() - assert "not a valid value" in caplog.text + assert "preset_modes must not include preset mode 'none'" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 6642d778f53..21d3bcce3a9 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1788,7 +1788,7 @@ async def test_attributes( }, False, None, - "not a valid value", + "speed_range_max must be > speed_range_min", ), ( "test14", @@ -1805,7 +1805,7 @@ async def test_attributes( }, False, None, - "not a valid value", + "speed_range_min must be > 0", ), ( "test15", diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 2aa8de388b1..93d73094885 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2134,7 +2134,7 @@ async def test_setup_manual_mqtt_with_platform_key( """Test set up a manual MQTT item with a platform key.""" assert await mqtt_mock_entry() assert ( - "extra keys not allowed @ data['platform'] for manual configured MQTT light item" + "extra keys not allowed @ data['platform'] for manually configured MQTT light item" in caplog.text ) @@ -2151,6 +2151,42 @@ async def test_setup_manual_mqtt_with_invalid_config( assert "required key not provided" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "sensor": { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + } + } + }, + { + mqtt.DOMAIN: { + "binary_sensor": { + "name": "test", + "state_topic": "test-topic", + "entity_category": "config", + } + } + }, + ], +) +@patch( + "homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR, Platform.SENSOR] +) +async def test_setup_manual_mqtt_with_invalid_entity_category( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test set up a manual sensor item with an invalid entity category.""" + assert await mqtt_mock_entry() + assert "Entity category `config` is invalid" in caplog.text + + @patch("homeassistant.components.mqtt.PLATFORMS", []) @pytest.mark.parametrize( ("mqtt_config_entry_data", "protocol"), diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 80f38dffcf9..a602f1e3065 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -211,7 +211,7 @@ async def test_attribute_validation_max_greater_then_min( ) -> None: """Test the validation of min and max configuration attributes.""" assert await mqtt_mock_entry() - assert "not a valid value" in caplog.text + assert "text length min must be >= max" in caplog.text @pytest.mark.parametrize( @@ -236,7 +236,7 @@ async def test_attribute_validation_max_not_greater_then_max_state_length( ) -> None: """Test the max value of of max configuration attribute.""" assert await mqtt_mock_entry() - assert "not a valid value" in caplog.text + assert "max text length must be <= 255" in caplog.text @pytest.mark.parametrize( From dc30ddc24bb8ed0e534187b1cc4db9730296143a Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 2 Nov 2023 09:13:04 +0100 Subject: [PATCH 100/643] Fix Fronius entity initialisation (#103211) * Use None instead of raising ValueError if value invalid * use async_dispatcher_send --- homeassistant/components/fronius/__init__.py | 4 ++-- homeassistant/components/fronius/sensor.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 793f381d52f..c05f18107a0 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from .const import ( @@ -204,7 +204,7 @@ class FroniusSolarNet: # Only for re-scans. Initial setup adds entities through sensor.async_setup_entry if self.config_entry.state == ConfigEntryState.LOADED: - dispatcher_send(self.hass, SOLAR_NET_DISCOVERY_NEW, _coordinator) + async_dispatcher_send(self.hass, SOLAR_NET_DISCOVERY_NEW, _coordinator) _LOGGER.debug( "New inverter added (UID: %s)", diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index dfc76ae1415..f11855ce7e2 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -661,7 +661,7 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn if new_value is None: return self.entity_description.default_value if self.entity_description.invalid_when_falsy and not new_value: - raise ValueError(f"Ignoring zero value for {self.entity_id}.") + return None if isinstance(new_value, float): return round(new_value, 4) return new_value @@ -671,10 +671,9 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn """Handle updated data from the coordinator.""" try: self._attr_native_value = self._get_entity_value() - except (KeyError, ValueError): + except KeyError: # sets state to `None` if no default_value is defined in entity description # KeyError: raised when omitted in response - eg. at night when no production - # ValueError: raised when invalid zero value received self._attr_native_value = self.entity_description.default_value self.async_write_ha_state() From c811e0db497df62a11214c45d5b6d8c1758b2eb8 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Thu, 2 Nov 2023 15:46:58 -0400 Subject: [PATCH 101/643] Bump pyenphase to 1.14.1 (#103239) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 0700bd4e71a..4cffcce2d5c 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.13.1"], + "requirements": ["pyenphase==1.14.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 7545800d5dc..328d0817705 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1693,7 +1693,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.13.1 +pyenphase==1.14.1 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ed2583ec3c..3e5689ea266 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1275,7 +1275,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.13.1 +pyenphase==1.14.1 # homeassistant.components.everlights pyeverlights==0.1.0 From 1dcd66d75c643a01e646e9bcb7166df673a4e12d Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 2 Nov 2023 16:47:33 +0100 Subject: [PATCH 102/643] Remove measurement flag from timestamp in gardena bluetooth (#103245) Remove measurement flag from timestamp --- homeassistant/components/gardena_bluetooth/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 396d8469ffc..495a1fcb1eb 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -88,7 +88,6 @@ DESCRIPTIONS = ( GardenaBluetoothSensorEntityDescription( key=Sensor.measurement_timestamp.uuid, translation_key="sensor_measurement_timestamp", - state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, char=Sensor.measurement_timestamp, From 06d26b7c7f8b5da968dd2a7d2c12f32f964842a2 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Nov 2023 12:17:36 +0100 Subject: [PATCH 103/643] Fix Plugwise Schedule selection (#103262) --- homeassistant/components/plugwise/select.py | 2 +- tests/components/plugwise/test_select.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 6646cce3369..138e5fe3b59 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -40,7 +40,7 @@ SELECT_TYPES = ( key="select_schedule", translation_key="select_schedule", icon="mdi:calendar-clock", - command=lambda api, loc, opt: api.set_schedule_state(loc, opt, STATE_ON), + command=lambda api, loc, opt: api.set_schedule_state(loc, STATE_ON, opt), options_key="available_schedules", ), PlugwiseSelectEntityDescription( diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index 7ec5559a608..9df20a5ffc8 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -40,5 +40,7 @@ async def test_adam_change_select_entity( assert mock_smile_adam.set_schedule_state.call_count == 1 mock_smile_adam.set_schedule_state.assert_called_with( - "c50f167537524366a5af7aa3942feb1e", "Badkamer Schema", "on" + "c50f167537524366a5af7aa3942feb1e", + "on", + "Badkamer Schema", ) From 92486b1ff0e76a8068fe03fa5173c630f86e605b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Nov 2023 22:00:43 -0500 Subject: [PATCH 104/643] Bump yalexs-ble to 2.3.2 (#103267) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 50df1f4bd1d..aacebb4bb5c 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.1"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.2"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 8d15fbb9a9f..be388ec563c 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.3.1"] + "requirements": ["yalexs-ble==2.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 328d0817705..354f72252a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2758,7 +2758,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.1 +yalexs-ble==2.3.2 # homeassistant.components.august yalexs==1.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e5689ea266..00b5b61e9d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2058,7 +2058,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.1 +yalexs-ble==2.3.2 # homeassistant.components.august yalexs==1.10.0 From ba634ac34612825dc445c8c1e3fe00296916ee45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Janu=C3=A1rio?= Date: Thu, 2 Nov 2023 22:32:46 +0000 Subject: [PATCH 105/643] add library logger info on ecoforest integration manifest (#103274) --- homeassistant/components/ecoforest/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/ecoforest/manifest.json b/homeassistant/components/ecoforest/manifest.json index 518f4d97a04..99b63fade5f 100644 --- a/homeassistant/components/ecoforest/manifest.json +++ b/homeassistant/components/ecoforest/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecoforest", "iot_class": "local_polling", + "loggers": ["pyecoforest"], "requirements": ["pyecoforest==0.3.0"] } From 1a823376d8a8d1a4e0f7b414cec8b58f73005178 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 3 Nov 2023 12:09:31 +0100 Subject: [PATCH 106/643] Fix Matter 1.2 locks with specific unlatch/unbolt support (#103275) --- homeassistant/components/matter/lock.py | 58 +- tests/components/matter/conftest.py | 10 + .../fixtures/nodes/door-lock-with-unbolt.json | 510 ++++++++++++++++++ tests/components/matter/test_door_lock.py | 49 ++ 4 files changed, 605 insertions(+), 22 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index a5f625f9e73..8491f58e387 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -1,12 +1,15 @@ """Matter lock.""" from __future__ import annotations -from enum import IntFlag from typing import Any from chip.clusters import Objects as clusters -from homeassistant.components.lock import LockEntity, LockEntityDescription +from homeassistant.components.lock import ( + LockEntity, + LockEntityDescription, + LockEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE, Platform from homeassistant.core import HomeAssistant, callback @@ -17,6 +20,8 @@ from .entity import MatterEntity from .helpers import get_matter from .models import MatterDiscoverySchema +DoorLockFeature = clusters.DoorLock.Bitmaps.Feature + async def async_setup_entry( hass: HomeAssistant, @@ -61,6 +66,14 @@ class MatterLock(MatterEntity, LockEntity): return bool(self.features & DoorLockFeature.kDoorPositionSensor) + @property + def supports_unbolt(self) -> bool: + """Return True if the lock supports unbolt.""" + if self.features is None: + return False + + return bool(self.features & DoorLockFeature.kUnbolt) + async def send_device_command( self, command: clusters.ClusterCommand, @@ -92,6 +105,25 @@ class MatterLock(MatterEntity, LockEntity): self._lock_option_default_code, ) code_bytes = code.encode() if code else None + if self.supports_unbolt: + # if the lock reports it has separate unbolt support, + # the unlock command should unbolt only on the unlock command + # and unlatch on the HA 'open' command. + await self.send_device_command( + command=clusters.DoorLock.Commands.UnboltDoor(code_bytes) + ) + else: + await self.send_device_command( + command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) + ) + + async def async_open(self, **kwargs: Any) -> None: + """Open the door latch.""" + code: str = kwargs.get( + ATTR_CODE, + self._lock_option_default_code, + ) + code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) ) @@ -104,6 +136,8 @@ class MatterLock(MatterEntity, LockEntity): self.features = int( self.get_matter_attribute_value(clusters.DoorLock.Attributes.FeatureMap) ) + if self.supports_unbolt: + self._attr_supported_features = LockEntityFeature.OPEN lock_state = self.get_matter_attribute_value( clusters.DoorLock.Attributes.LockState @@ -144,26 +178,6 @@ class MatterLock(MatterEntity, LockEntity): ) -class DoorLockFeature(IntFlag): - """Temp enum that represents the features of a door lock. - - Should be replaced by the library provided one once that is released. - """ - - kPinCredential = 0x1 # noqa: N815 - kRfidCredential = 0x2 # noqa: N815 - kFingerCredentials = 0x4 # noqa: N815 - kLogging = 0x8 # noqa: N815 - kWeekDayAccessSchedules = 0x10 # noqa: N815 - kDoorPositionSensor = 0x20 # noqa: N815 - kFaceCredentials = 0x40 # noqa: N815 - kCredentialsOverTheAirAccess = 0x80 # noqa: N815 - kUser = 0x100 # noqa: N815 - kNotification = 0x200 # noqa: N815 - kYearDayAccessSchedules = 0x400 # noqa: N815 - kHolidaySchedules = 0x800 # noqa: N815 - - DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LOCK, diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 6a14148585a..03443e4c4b9 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -223,6 +223,16 @@ async def door_lock_fixture( return await setup_integration_with_node_fixture(hass, "door-lock", matter_client) +@pytest.fixture(name="door_lock_with_unbolt") +async def door_lock_with_unbolt_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a door lock node with unbolt feature.""" + return await setup_integration_with_node_fixture( + hass, "door-lock-with-unbolt", matter_client + ) + + @pytest.fixture(name="eve_contact_sensor_node") async def eve_contact_sensor_node_fixture( hass: HomeAssistant, matter_client: MagicMock diff --git a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json new file mode 100644 index 00000000000..6cbd75ab09c --- /dev/null +++ b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json @@ -0,0 +1,510 @@ +{ + "node_id": 1, + "date_commissioned": "2023-03-07T09:06:06.059454", + "last_interview": "2023-03-07T09:06:06.059456", + "interview_version": 2, + "available": true, + "attributes": { + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [ + 29, 31, 40, 42, 43, 44, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 60, 62, + 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Door Lock", + "0/40/4": 32769, + "0/40/5": "Mock Door Lock", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-door-lock", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 65535 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/46/0": [0, 1], + "0/46/65532": 0, + "0/46/65533": 1, + "0/46/65528": [], + "0/46/65529": [], + "0/46/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "0/47/0": 1, + "0/47/1": 0, + "0/47/2": "USB", + "0/47/6": 0, + "0/47/65532": 1, + "0/47/65533": 1, + "0/47/65528": [], + "0/47/65529": [], + "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "eth0", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "/mQDt/2Q", + "IPv4Addresses": ["CjwBaQ=="], + "IPv6Addresses": [ + "/VqgxiAxQib8ZAP//rf9kA==", + "IAEEcLs7AAb8ZAP//rf9kA==", + "/oAAAAAAAAD8ZAP//rf9kA==" + ], + "type": 2 + }, + { + "name": "lo", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "AAAAAAAA", + "IPv4Addresses": ["fwAAAQ=="], + "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "type": 0 + } + ], + "0/51/1": 1, + "0/51/2": 25, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/52/0": [ + { + "id": 26957, + "name": "26957", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26956, + "name": "26956", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26955, + "name": "26955", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26953, + "name": "26953", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26952, + "name": "26952", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + } + ], + "0/52/1": 351120, + "0/52/2": 529520, + "0/52/3": 529520, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/53/0": null, + "0/53/1": null, + "0/53/2": null, + "0/53/3": null, + "0/53/4": null, + "0/53/5": null, + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": null, + "0/53/10": null, + "0/53/11": null, + "0/53/12": null, + "0/53/13": null, + "0/53/14": 0, + "0/53/15": 0, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 0, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 0, + "0/53/23": 0, + "0/53/24": 0, + "0/53/25": 0, + "0/53/26": 0, + "0/53/27": 0, + "0/53/28": 0, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 0, + "0/53/34": 0, + "0/53/35": 0, + "0/53/36": 0, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 0, + "0/53/40": 0, + "0/53/41": 0, + "0/53/42": 0, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 0, + "0/53/50": 0, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": null, + "0/53/57": null, + "0/53/58": null, + "0/53/59": null, + "0/53/60": null, + "0/53/61": null, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65530, 65531, + 65532, 65533 + ], + "0/55/0": null, + "0/55/1": false, + "0/55/2": 823, + "0/55/3": 969, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 25, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "fabricIndex": 1 + } + ], + "0/62/1": [ + { + "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEn+MvYx0Ot2r1NqEpJrlz90FzXro1EJTRb2XuEraRy5W033XkMuqJL5EAnaI8S9YrkVrBkcX9B0otmZORke53FjcKNQEpARgkAmAwBBTNCOnzxc3zr9iFm19YbsW93ltH6jAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQILjpR3BTSHHl6DQtvwzWkjmA+i5jjXdc3qjemFGFjFVAnV6dPLQo7tctC8Y0uL4ZNERga2/NZAt1gRD72S0YR4Y" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65530, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": 0, + "1/6/65532": 0, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "deviceType": 10, + "revision": 1 + } + ], + "1/29/1": [3, 6, 29, 47, 257], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "1/47/0": 1, + "1/47/1": 1, + "1/47/2": "Battery", + "1/47/14": 0, + "1/47/15": false, + "1/47/16": 0, + "1/47/19": "", + "1/47/65532": 10, + "1/47/65533": 1, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [ + 0, 1, 2, 14, 15, 16, 19, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "1/257/0": 1, + "1/257/1": 0, + "1/257/2": true, + "1/257/3": 1, + "1/257/17": 10, + "1/257/18": 10, + "1/257/19": 10, + "1/257/20": 10, + "1/257/21": 10, + "1/257/22": 10, + "1/257/23": 8, + "1/257/24": 6, + "1/257/25": 20, + "1/257/26": 10, + "1/257/27": 1, + "1/257/28": 5, + "1/257/33": "en", + "1/257/35": 60, + "1/257/36": 0, + "1/257/37": 0, + "1/257/38": 65526, + "1/257/41": false, + "1/257/43": false, + "1/257/48": 3, + "1/257/49": 10, + "1/257/51": false, + "1/257/65532": 7603, + "1/257/65533": 6, + "1/257/65528": [12, 15, 18, 28, 35, 37], + "1/257/65529": [ + 0, 1, 3, 11, 12, 13, 14, 15, 16, 17, 18, 19, 26, 27, 29, 34, 36, 38 + ], + "1/257/65531": [ + 0, 1, 2, 3, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 33, 35, 36, + 37, 38, 41, 43, 48, 49, 51, 65528, 65529, 65530, 65531, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py index 221ae891d67..a9753824edc 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -10,6 +10,7 @@ from homeassistant.components.lock import ( STATE_LOCKING, STATE_UNLOCKED, STATE_UNLOCKING, + LockEntityFeature, ) from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -135,3 +136,51 @@ async def test_lock_requires_pin( command=clusters.DoorLock.Commands.LockDoor(code.encode()), timed_request_timeout_ms=1000, ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_lock_with_unbolt( + hass: HomeAssistant, + matter_client: MagicMock, + door_lock_with_unbolt: MatterNode, +) -> None: + """Test door lock.""" + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_LOCKED + assert state.attributes["supported_features"] & LockEntityFeature.OPEN + # test unlock/unbolt + await hass.services.async_call( + "lock", + "unlock", + { + "entity_id": "lock.mock_door_lock", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + # unlock should unbolt on a lock with unbolt feature + assert matter_client.send_device_command.call_args == call( + node_id=door_lock_with_unbolt.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.UnboltDoor(), + timed_request_timeout_ms=1000, + ) + matter_client.send_device_command.reset_mock() + # test open / unlatch + await hass.services.async_call( + "lock", + "open", + { + "entity_id": "lock.mock_door_lock", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock_with_unbolt.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.UnlockDoor(), + timed_request_timeout_ms=1000, + ) From 910654bf785f46113179077c3e29c58bed6249ca Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 3 Nov 2023 07:08:40 -0400 Subject: [PATCH 107/643] Fix firmware update failure (#103277) --- homeassistant/components/zwave_js/update.py | 22 +++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 6efae29e46e..e49eb8a2017 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections import Counter from collections.abc import Callable -from dataclasses import asdict, dataclass +from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Final @@ -54,7 +54,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): def as_dict(self) -> dict[str, Any]: """Return a dict representation of the extra data.""" return { - ATTR_LATEST_VERSION_FIRMWARE: asdict(self.latest_version_firmware) + ATTR_LATEST_VERSION_FIRMWARE: self.latest_version_firmware.to_dict() if self.latest_version_firmware else None } @@ -339,19 +339,25 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): and (latest_version := state.attributes.get(ATTR_LATEST_VERSION)) is not None and (extra_data := await self.async_get_last_extra_data()) - ): - self._attr_latest_version = latest_version - self._latest_version_firmware = ( - ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( + and ( + latest_version_firmware := ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( extra_data.as_dict() ).latest_version_firmware ) - # If we have no state or latest version to restore, we can set the latest + ): + self._attr_latest_version = latest_version + self._latest_version_firmware = latest_version_firmware + # If we have no state or latest version to restore, or the latest version is + # the same as the installed version, we can set the latest # version to installed so that the entity starts as off. If we have partial # restore data due to an upgrade to an HA version where this feature is released # from one that is not the entity will start in an unknown state until we can # correct on next update - elif not state or not latest_version: + elif ( + not state + or not latest_version + or latest_version == self._attr_installed_version + ): self._attr_latest_version = self._attr_installed_version # Spread updates out in 5 minute increments to avoid flooding the network From 4a56d0ec1d1e84d7397cfb633b615cf765413969 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 3 Nov 2023 04:15:49 -0700 Subject: [PATCH 108/643] Bump opower to 0.0.39 (#103292) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index a27d6f6f680..1022ab07e2c 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.38"] + "requirements": ["opower==0.0.39"] } diff --git a/requirements_all.txt b/requirements_all.txt index 354f72252a3..374f5ee062e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1394,7 +1394,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.38 +opower==0.0.39 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00b5b61e9d1..b19fe7fffcf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1072,7 +1072,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.38 +opower==0.0.39 # homeassistant.components.oralb oralb-ble==0.17.6 From 0b0f099d2722de000aec715482aafb5ccf825f71 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Nov 2023 13:02:22 +0100 Subject: [PATCH 109/643] Bumped version to 2023.11.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 28241ef15f4..99c1312dc98 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 7efa6915a46..616dd219baf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0" +version = "2023.11.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 810681b3574afb2b687a40758cba3949a480d9ee Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 3 Nov 2023 17:05:27 +0100 Subject: [PATCH 110/643] Bump reolink-aio to 0.7.14 and improve typing of Reolink (#103129) * Improve typing * fix mypy * Further improve typing * Restore Literal typing * Bump reolink_aio to 0.7.13 * Bump reolink-aio to 0.7.14 --- homeassistant/components/reolink/__init__.py | 11 ++++++++--- .../components/reolink/binary_sensor.py | 6 +++--- homeassistant/components/reolink/host.py | 18 ++++++++++-------- homeassistant/components/reolink/light.py | 16 ++++++++-------- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/number.py | 4 ++-- homeassistant/components/reolink/sensor.py | 2 +- homeassistant/components/reolink/update.py | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 10 files changed, 37 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index fd62f8451fb..8425f29fbe8 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -10,6 +10,7 @@ from typing import Literal from reolink_aio.api import RETRY_ATTEMPTS from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError +from reolink_aio.software_version import NewSoftwareVersion from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform @@ -45,7 +46,9 @@ class ReolinkData: host: ReolinkHost device_coordinator: DataUpdateCoordinator[None] - firmware_coordinator: DataUpdateCoordinator[str | Literal[False]] + firmware_coordinator: DataUpdateCoordinator[ + str | Literal[False] | NewSoftwareVersion + ] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -86,7 +89,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): await host.renew() - async def async_check_firmware_update() -> str | Literal[False]: + async def async_check_firmware_update() -> str | Literal[ + False + ] | NewSoftwareVersion: """Check for firmware updates.""" if not host.api.supported(None, "update"): return False @@ -153,7 +158,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def entry_update_listener(hass: HomeAssistant, config_entry: ConfigEntry): +async def entry_update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Update the configuration of the host entity.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 49e964e2b3f..7f2ff3e0053 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -32,7 +32,7 @@ from .entity import ReolinkChannelCoordinatorEntity class ReolinkBinarySensorEntityDescriptionMixin: """Mixin values for Reolink binary sensor entities.""" - value: Callable[[Host, int | None], bool] + value: Callable[[Host, int], bool] @dataclass @@ -43,7 +43,7 @@ class ReolinkBinarySensorEntityDescription( icon: str = "mdi:motion-sensor" icon_off: str = "mdi:motion-sensor-off" - supported: Callable[[Host, int | None], bool] = lambda host, ch: True + supported: Callable[[Host, int], bool] = lambda host, ch: True BINARY_SENSORS = ( @@ -169,6 +169,6 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt ) ) - async def _async_handle_event(self, event): + async def _async_handle_event(self, event: str) -> None: """Handle incoming event for motion detection.""" self.async_write_ha_state() diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index d470711267d..0075bbac4e6 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping import logging -from typing import Any +from typing import Any, Literal import aiohttp from aiohttp.web import Request @@ -81,7 +81,7 @@ class ReolinkHost: return self._unique_id @property - def api(self): + def api(self) -> Host: """Return the API object.""" return self._api @@ -313,7 +313,7 @@ class ReolinkHost: """Call the API of the camera device to update the internal states.""" await self._api.get_states() - async def disconnect(self): + async def disconnect(self) -> None: """Disconnect from the API, so the connection will be released.""" try: await self._api.unsubscribe() @@ -335,7 +335,7 @@ class ReolinkHost: err, ) - async def _async_start_long_polling(self, initial=False): + async def _async_start_long_polling(self, initial=False) -> None: """Start ONVIF long polling task.""" if self._long_poll_task is None: try: @@ -364,7 +364,7 @@ class ReolinkHost: self._lost_subscription = False self._long_poll_task = asyncio.create_task(self._async_long_polling()) - async def _async_stop_long_polling(self): + async def _async_stop_long_polling(self) -> None: """Stop ONVIF long polling task.""" if self._long_poll_task is not None: self._long_poll_task.cancel() @@ -372,7 +372,7 @@ class ReolinkHost: await self._api.unsubscribe(sub_type=SubType.long_poll) - async def stop(self, event=None): + async def stop(self, event=None) -> None: """Disconnect the API.""" if self._cancel_poll is not None: self._cancel_poll() @@ -433,7 +433,7 @@ class ReolinkHost: else: self._lost_subscription = False - async def _renew(self, sub_type: SubType) -> None: + async def _renew(self, sub_type: Literal[SubType.push, SubType.long_poll]) -> None: """Execute the renew of the subscription.""" if not self._api.subscribed(sub_type): _LOGGER.debug( @@ -512,8 +512,10 @@ class ReolinkHost: _LOGGER.debug("Registered webhook: %s", event_id) - def unregister_webhook(self): + def unregister_webhook(self) -> None: """Unregister the webhook for motion events.""" + if self.webhook_id is None: + return _LOGGER.debug("Unregistering webhook %s", self.webhook_id) webhook.async_unregister(self._hass, self.webhook_id) self.webhook_id = None diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 4ac8166410f..938093df4a3 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -38,8 +38,8 @@ class ReolinkLightEntityDescription( """A class that describes light entities.""" supported_fn: Callable[[Host, int], bool] = lambda api, ch: True - get_brightness_fn: Callable[[Host, int], int] | None = None - set_brightness_fn: Callable[[Host, int, float], Any] | None = None + get_brightness_fn: Callable[[Host, int], int | None] | None = None + set_brightness_fn: Callable[[Host, int, int], Any] | None = None LIGHT_ENTITIES = ( @@ -127,13 +127,13 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): if self.entity_description.get_brightness_fn is None: return None - return round( - 255 - * ( - self.entity_description.get_brightness_fn(self._host.api, self._channel) - / 100.0 - ) + bright_pct = self.entity_description.get_brightness_fn( + self._host.api, self._channel ) + if bright_pct is None: + return None + + return round(255 * bright_pct / 100.0) async def async_turn_off(self, **kwargs: Any) -> None: """Turn light off.""" diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 1c1d8dd96b1..9189de89efa 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.7.12"] + "requirements": ["reolink-aio==0.7.14"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 24e5d1bd72b..6be0cef1670 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -26,7 +26,7 @@ from .entity import ReolinkChannelCoordinatorEntity class ReolinkNumberEntityDescriptionMixin: """Mixin values for Reolink number entities.""" - value: Callable[[Host, int], float] + value: Callable[[Host, int], float | None] method: Callable[[Host, int, float], Any] @@ -354,7 +354,7 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): ) @property - def native_value(self) -> float: + def native_value(self) -> float | None: """State of the number entity.""" return self.entity_description.value(self._host.api, self._channel) diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 6282f29e442..b9e8ddb8e73 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -44,7 +44,7 @@ class ReolinkSensorEntityDescription( class ReolinkHostSensorEntityDescriptionMixin: """Mixin values for Reolink host sensor entities.""" - value: Callable[[Host], int] + value: Callable[[Host], int | None] @dataclass diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 57efe1d9e92..1c10671550d 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -35,7 +35,8 @@ async def async_setup_entry( class ReolinkUpdateEntity( - ReolinkBaseCoordinatorEntity[str | Literal[False]], UpdateEntity + ReolinkBaseCoordinatorEntity[str | Literal[False] | NewSoftwareVersion], + UpdateEntity, ): """Update entity for a Netgear device.""" diff --git a/requirements_all.txt b/requirements_all.txt index 374f5ee062e..14b75e6cd6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2319,7 +2319,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.12 +reolink-aio==0.7.14 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b19fe7fffcf..bf601b3156b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1730,7 +1730,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.12 +reolink-aio==0.7.14 # homeassistant.components.rflink rflink==0.0.65 From 67ce51899f07d49c04fe421061f896ca1ef805c5 Mon Sep 17 00:00:00 2001 From: Ian Date: Fri, 3 Nov 2023 09:03:02 -0700 Subject: [PATCH 111/643] Bump py_nextbusnext to v1.0.2 to fix TypeError (#103214) * Bump py_nextbusnext to v1.0.1 to fix TypeError Currently throwing an error as a set is passed into the method that is currently expecting a Sequence. That method is technically compatible with Iterable, so the latest patch relaxes that restriction. * Bump version to v1.0.2 to fix error message --- homeassistant/components/nextbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 9d1490a4ae6..d8f4018ada2 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py-nextbusnext==1.0.0"] + "requirements": ["py-nextbusnext==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 14b75e6cd6d..b5386244ee0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1521,7 +1521,7 @@ py-improv-ble-client==1.0.3 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==1.0.0 +py-nextbusnext==1.0.2 # homeassistant.components.nightscout py-nightscout==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf601b3156b..d0dc4588022 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1166,7 +1166,7 @@ py-improv-ble-client==1.0.3 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==1.0.0 +py-nextbusnext==1.0.2 # homeassistant.components.nightscout py-nightscout==1.2.2 From d95d4d0184eb09cfce8d58aa21260bb17ddcd03f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 25 Oct 2023 16:07:22 -0400 Subject: [PATCH 112/643] Add script to convert zwave_js device diagnostics to fixture (#102799) --- homeassistant/components/zwave_js/README.md | 15 +- .../components/zwave_js/scripts/__init__.py | 1 + .../convert_device_diagnostics_to_fixture.py | 91 + .../zwave_js/fixtures/device_diagnostics.json | 2315 +++++++++++++++++ .../zwave_js/fixtures/zooz_zse44_state.json | 1330 ++++++++++ tests/components/zwave_js/scripts/__init__.py | 1 + ...t_convert_device_diagnostics_to_fixture.py | 80 + 7 files changed, 3832 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/zwave_js/scripts/__init__.py create mode 100644 homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py create mode 100644 tests/components/zwave_js/fixtures/device_diagnostics.json create mode 100644 tests/components/zwave_js/fixtures/zooz_zse44_state.json create mode 100644 tests/components/zwave_js/scripts/__init__.py create mode 100644 tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py diff --git a/homeassistant/components/zwave_js/README.md b/homeassistant/components/zwave_js/README.md index f82f421f752..da49e67c60a 100644 --- a/homeassistant/components/zwave_js/README.md +++ b/homeassistant/components/zwave_js/README.md @@ -10,7 +10,20 @@ The Z-Wave integration uses a discovery mechanism to create the necessary entiti In cases where an entity's functionality requires interaction with multiple Values, the discovery rule for that particular entity type is based on the primary Value, or the Value that must be there to indicate that this entity needs to be created, and then the rest of the Values required are discovered by the class instance for that entity. A good example of this is the discovery logic for the `climate` entity. Currently, the discovery logic is tied to the discovery of a Value with a property of `mode` and a command class of `Thermostat Mode`, but the actual entity uses many more Values than that to be fully functional as evident in the [code](./climate.py). -There are several ways that device support can be improved within Home Assistant, but regardless of the reason, it is important to add device specific tests in these use cases. To do so, add the device's data (from device diagnostics) to the [fixtures folder](../../../tests/components/zwave_js/fixtures) and then define the new fixtures in [conftest.py](../../../tests/components/zwave_js/conftest.py). Use existing tests as the model but the tests can go in the [test_discovery.py module](../../../tests/components/zwave_js/test_discovery.py). +There are several ways that device support can be improved within Home Assistant, but regardless of the reason, it is important to add device specific tests in these use cases. To do so, add the device's data to the [fixtures folder](../../../tests/components/zwave_js/fixtures) and then define the new fixtures in [conftest.py](../../../tests/components/zwave_js/conftest.py). Use existing tests as the model but the tests can go in the [test_discovery.py module](../../../tests/components/zwave_js/test_discovery.py). To learn how to generate fixtures, see the following section. + +### Generating device fixtures + +To generate a device fixture, download a diagnostics dump of the device from your Home Assistant instance. The dumped data will need to be modified to match the expected format. You can always do this transformation by hand, but the integration provides a [helper script](scripts/convert_device_diagnostics_to_fixture.py) that will generate the appropriate fixture data from a device diagnostics dump for you. To use it, run the script with the path to the diagnostics dump you downloaded: + +`python homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py ` + +The script will print the fixture data to standard output, and you can use Unix piping to create a file from the fixture data: + +`python homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py > ` + +You can alternatively pass the `--file` flag to the script and it will create the file for you in the [fixtures folder](../../../tests/components/zwave_js/fixtures): +`python homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py --file` ### Switching HA support for a device from one entity type to another. diff --git a/homeassistant/components/zwave_js/scripts/__init__.py b/homeassistant/components/zwave_js/scripts/__init__.py new file mode 100644 index 00000000000..fda5d0f5c39 --- /dev/null +++ b/homeassistant/components/zwave_js/scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts module for Z-Wave JS.""" diff --git a/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py new file mode 100644 index 00000000000..1e8d295227f --- /dev/null +++ b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py @@ -0,0 +1,91 @@ +"""Script to convert a device diagnostics file to a fixture.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from homeassistant.util import slugify + + +def get_arguments() -> argparse.Namespace: + """Get parsed passed in arguments.""" + parser = argparse.ArgumentParser(description="Z-Wave JS Fixture generator") + parser.add_argument( + "diagnostics_file", type=Path, help="Device diagnostics file to convert" + ) + parser.add_argument( + "--file", + action="store_true", + help=( + "Dump fixture to file in fixtures folder. By default, the fixture will be " + "printed to standard output." + ), + ) + + arguments = parser.parse_args() + + return arguments + + +def get_fixtures_dir_path(data: dict) -> Path: + """Get path to fixtures directory.""" + device_config = data["deviceConfig"] + filename = slugify( + f"{device_config['manufacturer']}-{device_config['label']}_state" + ) + path = Path(__file__).parents[1] + index = path.parts.index("homeassistant") + return Path( + *path.parts[:index], + "tests", + *path.parts[index + 1 :], + "fixtures", + f"{filename}.json", + ) + + +def load_file(path: Path) -> Any: + """Load file from path.""" + return json.loads(path.read_text("utf8")) + + +def extract_fixture_data(diagnostics_data: Any) -> dict: + """Extract fixture data from file.""" + if ( + not isinstance(diagnostics_data, dict) + or "data" not in diagnostics_data + or "state" not in diagnostics_data["data"] + ): + raise ValueError("Invalid diagnostics file format") + state: dict = diagnostics_data["data"]["state"] + if isinstance(state["values"], list): + return state + values_dict: dict[str, dict] = state.pop("values") + state["values"] = list(values_dict.values()) + + return state + + +def create_fixture_file(path: Path, state_text: str) -> None: + """Create a file for the state dump in the fixtures directory.""" + path.write_text(state_text, "utf8") + + +def main() -> None: + """Run the main script.""" + args = get_arguments() + diagnostics_path: Path = args.diagnostics_file + diagnostics = load_file(diagnostics_path) + fixture_data = extract_fixture_data(diagnostics) + fixture_text = json.dumps(fixture_data, indent=2) + if args.file: + fixture_path = get_fixtures_dir_path(fixture_data) + create_fixture_file(fixture_path, fixture_text) + return + print(fixture_text) # noqa: T201 + + +if __name__ == "__main__": + main() diff --git a/tests/components/zwave_js/fixtures/device_diagnostics.json b/tests/components/zwave_js/fixtures/device_diagnostics.json new file mode 100644 index 00000000000..a206cb8353c --- /dev/null +++ b/tests/components/zwave_js/fixtures/device_diagnostics.json @@ -0,0 +1,2315 @@ +{ + "home_assistant": { + "installation_type": "Home Assistant OS", + "version": "2023.10.5", + "dev": false, + "hassio": true, + "virtualenv": false, + "python_version": "3.11.5", + "docker": true, + "arch": "aarch64", + "timezone": "America/New_York", + "os_name": "Linux", + "os_version": "6.1.56", + "supervisor": "2023.10.1", + "host_os": "Home Assistant OS 11.0", + "docker_version": "24.0.6", + "chassis": "embedded", + "run_as_root": true + }, + "custom_components": { + "pyscript": { + "version": "1.5.0", + "requirements": ["croniter==1.3.8", "watchdog==2.3.1"] + } + }, + "integration_manifest": { + "domain": "zwave_js", + "name": "Z-Wave", + "codeowners": ["@home-assistant/z-wave"], + "config_flow": true, + "dependencies": ["http", "repairs", "usb", "websocket_api"], + "documentation": "https://www.home-assistant.io/integrations/zwave_js", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["zwave_js_server"], + "quality_scale": "platinum", + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.1"], + "usb": [ + { + "vid": "0658", + "pid": "0200", + "known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"] + }, + { + "vid": "10C4", + "pid": "8A2A", + "description": "*z-wave*", + "known_devices": ["Nortek HUSBZB-1"] + } + ], + "zeroconf": ["_zwave-js-server._tcp.local."], + "is_built_in": true + }, + "data": { + "versionInfo": { + "driverVersion": "12.2.1", + "serverVersion": "1.33.0", + "minSchemaVersion": 0, + "maxSchemaVersion": 33 + }, + "entities": [ + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_heat_alarm_heat_sensor_status", + "original_name": "Heat Alarm Heat sensor status", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Heat Alarm-Heat sensor status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Heat Alarm", + "property_name": "Heat Alarm", + "property_key": "Heat sensor status", + "property_key_name": "Heat sensor status" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_weather_alarm_moisture_alarm_status", + "original_name": "Weather Alarm Moisture alarm status", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Weather Alarm-Moisture alarm status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Weather Alarm", + "property_name": "Weather Alarm", + "property_key": "Moisture alarm status", + "property_key_name": "Moisture alarm status" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_alarmtype", + "original_name": "Alarm Type", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-alarmType", + "primary_value": null + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_alarmlevel", + "original_name": "Alarm Level", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-alarmLevel", + "primary_value": null + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_battery_level", + "original_name": "Battery level", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-128-0-level", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "level", + "property_name": "level", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_charging_status", + "original_name": "Charging status", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-128-0-chargingStatus", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "chargingStatus", + "property_name": "chargingStatus", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_recharge_or_replace", + "original_name": "Recharge or replace", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-128-0-rechargeOrReplace", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "rechargeOrReplace", + "property_name": "rechargeOrReplace", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_node_identify_on_off_period_duration", + "original_name": "Node Identify - On/Off Period: Duration", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-80-3", + "primary_value": { + "command_class": 135, + "command_class_name": "Indicator", + "endpoint": 0, + "property": 80, + "property_name": "Node Identify", + "property_key": 3, + "property_key_name": "On/Off Period: Duration" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_node_identify_on_off_cycle_count", + "original_name": "Node Identify - On/Off Cycle Count", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-80-4", + "primary_value": { + "command_class": 135, + "command_class_name": "Indicator", + "endpoint": 0, + "property": 80, + "property_name": "Node Identify", + "property_key": 4, + "property_key_name": "On/Off Cycle Count" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_node_identify_on_off_period_on_time", + "original_name": "Node Identify - On/Off Period: On time", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-80-5", + "primary_value": { + "command_class": 135, + "command_class_name": "Indicator", + "endpoint": 0, + "property": 80, + "property_name": "Node Identify", + "property_key": 5, + "property_key_name": "On/Off Period: On time" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_indicator_value", + "original_name": "Indicator value", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-value", + "primary_value": null + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_low_battery_level", + "original_name": "Low battery level", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-isLow", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "isLow", + "property_name": "isLow", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_rechargeable", + "original_name": "Rechargeable", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-rechargeable", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "rechargeable", + "property_name": "rechargeable", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_used_as_backup", + "original_name": "Used as backup", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-backup", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "backup", + "property_name": "backup", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_overheating", + "original_name": "Overheating", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-overheating", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "overheating", + "property_name": "overheating", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_fluid_is_low", + "original_name": "Fluid is low", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-lowFluid", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "lowFluid", + "property_name": "lowFluid", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_battery_is_disconnected", + "original_name": "Battery is disconnected", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-disconnected", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "disconnected", + "property_name": "disconnected", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_battery_temperature_is_low", + "original_name": "Battery temperature is low", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-lowTemperatureStatus", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "lowTemperatureStatus", + "property_name": "lowTemperatureStatus", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_air_temperature", + "original_name": "Air temperature", + "original_device_class": "temperature", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "\u00b0F", + "value_id": "23-49-0-Air temperature", + "primary_value": { + "command_class": 49, + "command_class_name": "Multilevel Sensor", + "endpoint": 0, + "property": "Air temperature", + "property_name": "Air temperature", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_underheat_detected", + "original_name": "Underheat detected", + "original_device_class": "heat", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Heat Alarm-Heat sensor status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Heat Alarm", + "property_name": "Heat Alarm", + "property_key": "Heat sensor status", + "property_key_name": "Heat sensor status", + "state_key": 6 + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_humidity", + "original_name": "Humidity", + "original_device_class": "humidity", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-49-0-Humidity", + "primary_value": { + "command_class": 49, + "command_class_name": "Multilevel Sensor", + "endpoint": 0, + "property": "Humidity", + "property_name": "Humidity", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_moisture_alarm", + "original_name": "Moisture alarm", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Weather Alarm-Moisture alarm status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Weather Alarm", + "property_name": "Weather Alarm", + "property_key": "Moisture alarm status", + "property_key_name": "Moisture alarm status", + "state_key": 2 + } + }, + { + "domain": "button", + "entity_id": "button.2nd_floor_sensor_idle_heat_sensor_status", + "original_name": "Idle Heat Alarm Heat sensor status", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Heat Alarm-Heat sensor status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Heat Alarm", + "property_name": "Heat Alarm", + "property_key": "Heat sensor status", + "property_key_name": "Heat sensor status" + } + }, + { + "domain": "button", + "entity_id": "button.2nd_floor_sensor_idle_moisture_alarm_status", + "original_name": "Idle Weather Alarm Moisture alarm status", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Weather Alarm-Moisture alarm status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Weather Alarm", + "property_name": "Weather Alarm", + "property_key": "Moisture alarm status", + "property_key_name": "Moisture alarm status" + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_high_temperature_alert_reporting", + "original_name": "High Temperature Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-6", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 6, + "property_name": "High Temperature Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_low_temperature_alert_reporting", + "original_name": "Low Temperature Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-8", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 8, + "property_name": "Low Temperature Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_high_humidity_alert_reporting", + "original_name": "High Humidity Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-10", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 10, + "property_name": "High Humidity Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_low_humidity_alert_reporting", + "original_name": "Low Humidity Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-12", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 12, + "property_name": "Low Humidity Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_temperature_scale", + "original_name": "Temperature Scale", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-13", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 13, + "property_name": "Temperature Scale", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_battery_report_threshold", + "original_name": "Battery Report Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-1", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 1, + "property_name": "Battery Report Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_low_battery_alarm_threshold", + "original_name": "Low Battery Alarm Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-2", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 2, + "property_name": "Low Battery Alarm Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_temperature_report_threshold", + "original_name": "Temperature Report Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "0.1 \u00b0F/C", + "value_id": "23-112-0-3", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 3, + "property_name": "Temperature Report Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_humidity_report_threshold", + "original_name": "Humidity Report Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-4", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 4, + "property_name": "Humidity Report Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_high_temperature_alert_threshold", + "original_name": "High Temperature Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "\u00b0F/C", + "value_id": "23-112-0-5", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 5, + "property_name": "High Temperature Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_low_temperature_alert_threshold", + "original_name": "Low Temperature Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "\u00b0F/C", + "value_id": "23-112-0-7", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 7, + "property_name": "Low Temperature Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_high_humidity_alert_threshold", + "original_name": "High Humidity Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-9", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 9, + "property_name": "High Humidity Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_low_humidity_alert_threshold", + "original_name": "Low Humidity Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-11", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 11, + "property_name": "Low Humidity Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_temperature_offset", + "original_name": "Temperature Offset", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "0.1 \u00b0F/C", + "value_id": "23-112-0-14", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 14, + "property_name": "Temperature Offset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_humidity_offset", + "original_name": "Humidity Offset", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "0.1 %", + "value_id": "23-112-0-15", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 15, + "property_name": "Humidity Offset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_temperature_reporting_interval", + "original_name": "Temperature Reporting Interval", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "minutes", + "value_id": "23-112-0-16", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 16, + "property_name": "Temperature Reporting Interval", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_humidity_reporting_interval", + "original_name": "Humidity Reporting Interval", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "minutes", + "value_id": "23-112-0-17", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 17, + "property_name": "Humidity Reporting Interval", + "property_key": null, + "property_key_name": null + } + } + ], + "state": { + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 634, + "productId": 57348, + "productType": 28672, + "firmwareVersion": "1.10", + "zwavePlusVersion": 2, + "name": "2nd Floor Sensor", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x027a/zse44.json", + "isEmbedded": true, + "manufacturer": "Zooz", + "manufacturerId": 634, + "label": "ZSE44", + "description": "Temperature Humidity XS Sensor", + "devices": [ + { + "productType": 28672, + "productId": 57348 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Initiate inclusion (pairing) in the app (or web interface). Not sure how? ask@getzooz.com\nWhile the hub is looking for new devices, click the Z-Wave button 3 times as quickly as possible. The LED indicator will start flashing to confirm inclusion mode and turn off once inclusion is completed.", + "exclusion": "1. Bring the sensor within direct range of your Z-Wave hub.\n2. Put the Z-Wave hub into exclusion mode (not sure how to do that? ask@getzooz.com).\n3. Click the Z-Wave button 3 times as quickly as possible.\n4. Your hub will confirm exclusion and the sensor will disappear from your controller's device list", + "reset": "When your network\u2019s primary controller is missing or otherwise inoperable, you may need to reset the device to factory settings manually. In order to complete the process, make sure the sensor is powered, then click the Z-Wave button twice and hold it the third time for 10 seconds. The LED indicator will blink continuously. Immediately after, click the Z-Wave button twice more to finalize the reset. The LED indicator will flash 3 times to confirm a successful reset", + "manual": "https://cdn.shopify.com/s/files/1/0218/7704/files/zooz-700-series-tilt-shock-xs-sensor-zse43-manual.pdf" + } + }, + "label": "ZSE44", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x027a:0x7000:0xe004:1.10", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "lwr": { + "repeaters": [2], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2023-08-09T13:26:05.031Z", + "values": { + "23-49-0-Air temperature": { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 1 + }, + "unit": "\u00b0F", + "stateful": true, + "secret": false + }, + "value": 69.9 + }, + "23-49-0-Humidity": { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Humidity", + "propertyName": "Humidity", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Humidity", + "ccSpecific": { + "sensorType": 5, + "scale": 0 + }, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 54 + }, + "23-112-0-1": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Battery Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Report Threshold", + "default": 5, + "min": 1, + "max": 10, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + "23-112-0-2": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Low Battery Alarm Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Battery Alarm Threshold", + "default": 20, + "min": 10, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + "23-112-0-3": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Temperature Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Report Threshold", + "default": 20, + "min": 10, + "max": 100, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + "23-112-0-4": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Humidity Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Report Threshold", + "default": 10, + "min": 1, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + "23-112-0-5": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "High Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Threshold", + "default": 120, + "min": 50, + "max": 120, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 120 + }, + "23-112-0-6": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "High Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 2 only", + "3": "0xff (on) to Lifeline and Group 2", + "4": "0x00 (off) to Group 2 only", + "5": "0x00 (off) to Lifeline and Group 2", + "6": "0xff (on) and 0x00 (off) to Group 2 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 2" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-7": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Low Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Threshold", + "default": 10, + "min": 10, + "max": 100, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + "23-112-0-8": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Low Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 3 only", + "3": "0xff (on) to Lifeline and Group 3", + "4": "0x00 (off) to Group 3 only", + "5": "0x00 (off) to Lifeline and Group 3", + "6": "0xff (on) and 0x00 (off) to Group 3 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 3" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-9": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "High Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + "23-112-0-10": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "High Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 4 only", + "3": "0xff (on) to Lifeline and Group 4", + "4": "0x00 (off) to Group 4 only", + "5": "0x00 (off) to Lifeline and Group 4", + "6": "0xff (on) and 0x00 (off) to Group 4 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 4" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-11": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Low Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + "23-112-0-12": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Low Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 5 only", + "3": "0xff (on) to Lifeline and Group 5", + "4": "0x00 (off) to Group 5 only", + "5": "0x00 (off) to Lifeline and Group 5", + "6": "0xff (on) and 0x00 (off) to Group 5 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 5" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-13": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Temperature Scale", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Scale", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Celsius", + "1": "Fahrenheit" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + "23-112-0-14": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Temperature Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Temperature Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + "23-112-0-15": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Humidity Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Humidity Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 %", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + "23-112-0-16": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Temperature Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + "23-112-0-17": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Humidity Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + "23-113-0-Heat Alarm-Heat sensor status": { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Heat Alarm", + "propertyKey": "Heat sensor status", + "propertyName": "Heat Alarm", + "propertyKeyName": "Heat sensor status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Heat sensor status", + "ccSpecific": { + "notificationType": 4 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Underheat detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-113-0-Weather Alarm-Moisture alarm status": { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Weather Alarm", + "propertyKey": "Moisture alarm status", + "propertyName": "Weather Alarm", + "propertyKeyName": "Moisture alarm status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Moisture alarm status", + "ccSpecific": { + "notificationType": 16 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Moisture alarm" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-114-0-manufacturerId": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 634 + }, + "23-114-0-productType": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 28672 + }, + "23-114-0-productId": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 57348 + }, + "23-128-0-level": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-128-0-isLow": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": true + }, + "23-128-0-chargingStatus": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "chargingStatus", + "propertyName": "chargingStatus", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Charging status", + "min": 0, + "max": 255, + "states": { + "0": "Discharging", + "1": "Charging", + "2": "Maintaining" + }, + "stateful": true, + "secret": false + } + }, + "23-128-0-rechargeable": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeable", + "propertyName": "rechargeable", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Rechargeable", + "stateful": true, + "secret": false + } + }, + "23-128-0-backup": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "backup", + "propertyName": "backup", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Used as backup", + "stateful": true, + "secret": false + } + }, + "23-128-0-overheating": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "overheating", + "propertyName": "overheating", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Overheating", + "stateful": true, + "secret": false + } + }, + "23-128-0-lowFluid": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowFluid", + "propertyName": "lowFluid", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Fluid is low", + "stateful": true, + "secret": false + } + }, + "23-128-0-rechargeOrReplace": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeOrReplace", + "propertyName": "rechargeOrReplace", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Recharge or replace", + "min": 0, + "max": 255, + "states": { + "0": "No", + "1": "Soon", + "2": "Now" + }, + "stateful": true, + "secret": false + } + }, + "23-128-0-disconnected": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "disconnected", + "propertyName": "disconnected", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery is disconnected", + "stateful": true, + "secret": false + } + }, + "23-128-0-lowTemperatureStatus": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowTemperatureStatus", + "propertyName": "lowTemperatureStatus", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery temperature is low", + "stateful": true, + "secret": false + } + }, + "23-132-0-wakeUpInterval": { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 1, + "metadata": { + "type": "number", + "default": 21600, + "readable": false, + "writeable": true, + "min": 3600, + "max": 86400, + "steps": 60, + "stateful": true, + "secret": false + }, + "value": 21600 + }, + "23-132-0-controllerNodeId": { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + "23-134-0-libraryType": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + "23-134-0-protocolVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.13" + }, + "23-134-0-firmwareVersions": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.10"] + }, + "23-134-0-hardwareVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + "23-134-0-sdkVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationFrameworkAPIVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationFrameworkBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + } + }, + "23-134-0-hostInterfaceVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + } + }, + "23-134-0-hostInterfaceBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + } + }, + "23-134-0-zWaveProtocolVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + } + }, + "23-134-0-zWaveProtocolBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + } + }, + "23-135-0-80-3": { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-135-0-80-4": { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-135-0-80-5": { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + } + }, + "endpoints": { + "0": { + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 4, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 3, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 4, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 7, + "isSecure": true + } + ] + } + } + } + } +} diff --git a/tests/components/zwave_js/fixtures/zooz_zse44_state.json b/tests/components/zwave_js/fixtures/zooz_zse44_state.json new file mode 100644 index 00000000000..a2fb5421fb7 --- /dev/null +++ b/tests/components/zwave_js/fixtures/zooz_zse44_state.json @@ -0,0 +1,1330 @@ +{ + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 634, + "productId": 57348, + "productType": 28672, + "firmwareVersion": "1.10", + "zwavePlusVersion": 2, + "name": "2nd Floor Sensor", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x027a/zse44.json", + "isEmbedded": true, + "manufacturer": "Zooz", + "manufacturerId": 634, + "label": "ZSE44", + "description": "Temperature Humidity XS Sensor", + "devices": [ + { + "productType": 28672, + "productId": 57348 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Initiate inclusion (pairing) in the app (or web interface). Not sure how? ask@getzooz.com\nWhile the hub is looking for new devices, click the Z-Wave button 3 times as quickly as possible. The LED indicator will start flashing to confirm inclusion mode and turn off once inclusion is completed.", + "exclusion": "1. Bring the sensor within direct range of your Z-Wave hub.\n2. Put the Z-Wave hub into exclusion mode (not sure how to do that? ask@getzooz.com).\n3. Click the Z-Wave button 3 times as quickly as possible.\n4. Your hub will confirm exclusion and the sensor will disappear from your controller's device list", + "reset": "When your network\u2019s primary controller is missing or otherwise inoperable, you may need to reset the device to factory settings manually. In order to complete the process, make sure the sensor is powered, then click the Z-Wave button twice and hold it the third time for 10 seconds. The LED indicator will blink continuously. Immediately after, click the Z-Wave button twice more to finalize the reset. The LED indicator will flash 3 times to confirm a successful reset", + "manual": "https://cdn.shopify.com/s/files/1/0218/7704/files/zooz-700-series-tilt-shock-xs-sensor-zse43-manual.pdf" + } + }, + "label": "ZSE44", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x027a:0x7000:0xe004:1.10", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "lwr": { + "repeaters": [2], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2023-08-09T13:26:05.031Z", + "endpoints": { + "0": { + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 4, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 3, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 4, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 7, + "isSecure": true + } + ] + } + }, + "values": [ + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 1 + }, + "unit": "\u00b0F", + "stateful": true, + "secret": false + }, + "value": 69.9 + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Humidity", + "propertyName": "Humidity", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Humidity", + "ccSpecific": { + "sensorType": 5, + "scale": 0 + }, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 54 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Battery Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Report Threshold", + "default": 5, + "min": 1, + "max": 10, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Low Battery Alarm Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Battery Alarm Threshold", + "default": 20, + "min": 10, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Temperature Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Report Threshold", + "default": 20, + "min": 10, + "max": 100, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Humidity Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Report Threshold", + "default": 10, + "min": 1, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "High Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Threshold", + "default": 120, + "min": 50, + "max": 120, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 120 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "High Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 2 only", + "3": "0xff (on) to Lifeline and Group 2", + "4": "0x00 (off) to Group 2 only", + "5": "0x00 (off) to Lifeline and Group 2", + "6": "0xff (on) and 0x00 (off) to Group 2 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 2" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Low Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Threshold", + "default": 10, + "min": 10, + "max": 100, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Low Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 3 only", + "3": "0xff (on) to Lifeline and Group 3", + "4": "0x00 (off) to Group 3 only", + "5": "0x00 (off) to Lifeline and Group 3", + "6": "0xff (on) and 0x00 (off) to Group 3 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 3" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "High Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "High Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 4 only", + "3": "0xff (on) to Lifeline and Group 4", + "4": "0x00 (off) to Group 4 only", + "5": "0x00 (off) to Lifeline and Group 4", + "6": "0xff (on) and 0x00 (off) to Group 4 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 4" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Low Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Low Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 5 only", + "3": "0xff (on) to Lifeline and Group 5", + "4": "0x00 (off) to Group 5 only", + "5": "0x00 (off) to Lifeline and Group 5", + "6": "0xff (on) and 0x00 (off) to Group 5 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 5" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Temperature Scale", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Scale", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Celsius", + "1": "Fahrenheit" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Temperature Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Temperature Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Humidity Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Humidity Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 %", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Temperature Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Humidity Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Heat Alarm", + "propertyKey": "Heat sensor status", + "propertyName": "Heat Alarm", + "propertyKeyName": "Heat sensor status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Heat sensor status", + "ccSpecific": { + "notificationType": 4 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Underheat detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Weather Alarm", + "propertyKey": "Moisture alarm status", + "propertyName": "Weather Alarm", + "propertyKeyName": "Moisture alarm status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Moisture alarm status", + "ccSpecific": { + "notificationType": 16 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Moisture alarm" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 634 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 28672 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 57348 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "chargingStatus", + "propertyName": "chargingStatus", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Charging status", + "min": 0, + "max": 255, + "states": { + "0": "Discharging", + "1": "Charging", + "2": "Maintaining" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeable", + "propertyName": "rechargeable", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Rechargeable", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "backup", + "propertyName": "backup", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Used as backup", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "overheating", + "propertyName": "overheating", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Overheating", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowFluid", + "propertyName": "lowFluid", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Fluid is low", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeOrReplace", + "propertyName": "rechargeOrReplace", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Recharge or replace", + "min": 0, + "max": 255, + "states": { + "0": "No", + "1": "Soon", + "2": "Now" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "disconnected", + "propertyName": "disconnected", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery is disconnected", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowTemperatureStatus", + "propertyName": "lowTemperatureStatus", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery temperature is low", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 1, + "metadata": { + "type": "number", + "default": 21600, + "readable": false, + "writeable": true, + "min": 3600, + "max": 86400, + "steps": 60, + "stateful": true, + "secret": false + }, + "value": 21600 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.13" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.10"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + } + ] +} diff --git a/tests/components/zwave_js/scripts/__init__.py b/tests/components/zwave_js/scripts/__init__.py new file mode 100644 index 00000000000..96d81d993e9 --- /dev/null +++ b/tests/components/zwave_js/scripts/__init__.py @@ -0,0 +1 @@ +"""Tests for zwave_js scripts.""" diff --git a/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py new file mode 100644 index 00000000000..d1e12e7abb4 --- /dev/null +++ b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py @@ -0,0 +1,80 @@ +"""Test convert_device_diagnostics_to_fixture script.""" +import copy +import json +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +from homeassistant.components.zwave_js.scripts.convert_device_diagnostics_to_fixture import ( + extract_fixture_data, + get_fixtures_dir_path, + load_file, + main, +) + +from tests.common import load_fixture + + +def _minify(text: str) -> str: + """Minify string by removing whitespace and new lines.""" + return text.replace(" ", "").replace("\n", "") + + +def test_fixture_functions() -> None: + """Test functions related to the fixture.""" + diagnostics_data = json.loads(load_fixture("zwave_js/device_diagnostics.json")) + state = extract_fixture_data(copy.deepcopy(diagnostics_data)) + assert isinstance(state["values"], list) + assert ( + get_fixtures_dir_path(state) + == Path(__file__).parents[1] / "fixtures" / "zooz_zse44_state.json" + ) + + old_diagnostics_format_data = copy.deepcopy(diagnostics_data) + old_diagnostics_format_data["data"]["state"]["values"] = list( + old_diagnostics_format_data["data"]["state"]["values"].values() + ) + assert ( + extract_fixture_data(old_diagnostics_format_data) + == old_diagnostics_format_data["data"]["state"] + ) + + with pytest.raises(ValueError): + extract_fixture_data({}) + + +def test_load_file() -> None: + """Test load file.""" + assert load_file( + Path(__file__).parents[1] / "fixtures" / "device_diagnostics.json" + ) == json.loads(load_fixture("zwave_js/device_diagnostics.json")) + + +def test_main(capfd: pytest.CaptureFixture[str]) -> None: + """Test main function.""" + Path(__file__).parents[1] / "fixtures" / "zooz_zse44_state.json" + fixture_str = load_fixture("zwave_js/zooz_zse44_state.json") + fixture_dict = json.loads(fixture_str) + + # Test dump to stdout + args = [ + "homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py", + str(Path(__file__).parents[1] / "fixtures" / "device_diagnostics.json"), + ] + with patch.object(sys, "argv", args): + main() + + captured = capfd.readouterr() + assert _minify(captured.out) == _minify(fixture_str) + + # Check file dump + args.append("--file") + with patch.object(sys, "argv", args), patch( + "homeassistant.components.zwave_js.scripts.convert_device_diagnostics_to_fixture.Path.write_text" + ) as write_text_mock: + main() + + assert len(write_text_mock.call_args_list) == 1 + assert write_text_mock.call_args[0][0] == json.dumps(fixture_dict, indent=2) From 718901d2addc539d5fffdfbb311bfd726f16c18d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 3 Nov 2023 17:16:20 +0100 Subject: [PATCH 113/643] Fix typo in Todoist config flow (#103317) --- homeassistant/components/todoist/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 442114eb118..0f81702a4d0 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -5,7 +5,7 @@ "data": { "token": "[%key:common::config_flow::data::api_token%]" }, - "description": "Please entry your API token from your [Todoist Settings page]({settings_url})" + "description": "Please enter your API token from your [Todoist Settings page]({settings_url})" } }, "error": { From 730a3f787002294375ca361ea80d79c2ae431437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 3 Nov 2023 17:44:48 +0100 Subject: [PATCH 114/643] Remove extra from traccar webhook (#103319) --- homeassistant/components/traccar/__init__.py | 3 ++- tests/components/traccar/test_init.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index c428ce7a5b1..5dffd629e80 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -50,7 +50,8 @@ WEBHOOK_SCHEMA = vol.Schema( vol.Optional(ATTR_BEARING): vol.Coerce(float), vol.Optional(ATTR_SPEED): vol.Coerce(float), vol.Optional(ATTR_TIMESTAMP): vol.Coerce(int), - } + }, + extra=vol.REMOVE_EXTRA, ) diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py index ccae59932de..1ac7adfb549 100644 --- a/tests/components/traccar/test_init.py +++ b/tests/components/traccar/test_init.py @@ -153,6 +153,7 @@ async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None "speed": 100, "bearing": "105.32", "altitude": 102, + "charge": "true", } req = await client.post(url, params=data) @@ -165,6 +166,7 @@ async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None assert state.attributes["speed"] == 100.0 assert state.attributes["bearing"] == 105.32 assert state.attributes["altitude"] == 102.0 + assert "charge" not in state.attributes data = { "lat": str(HOME_LATITUDE), From 8a07c10d886a8990eba567d74065689e96f59d03 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 4 Nov 2023 09:48:02 +0100 Subject: [PATCH 115/643] Report correct weather condition at night for Met (#103334) * Report correct weather condition at night for Met, fixes #68369, fixes #89001 * Update homeassistant/components/met/weather.py Co-authored-by: Jan-Philipp Benecke --------- Co-authored-by: Jan-Philipp Benecke --- homeassistant/components/met/weather.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 8a5c405c1c1..97b99e826cd 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -31,13 +31,21 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, sun from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM from . import MetDataUpdateCoordinator -from .const import ATTR_MAP, CONDITIONS_MAP, CONF_TRACK_HOME, DOMAIN, FORECAST_MAP +from .const import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_SUNNY, + ATTR_MAP, + CONDITIONS_MAP, + CONF_TRACK_HOME, + DOMAIN, + FORECAST_MAP, +) DEFAULT_NAME = "Met.no" @@ -141,6 +149,10 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): condition = self.coordinator.data.current_weather_data.get("condition") if condition is None: return None + + if condition == ATTR_CONDITION_SUNNY and not sun.is_up(self.hass): + condition = ATTR_CONDITION_CLEAR_NIGHT + return format_condition(condition) @property From 42243f14332b502c0cb424c67ca4f164cc6d57c4 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 4 Nov 2023 12:19:56 +0100 Subject: [PATCH 116/643] Handle UniFi traffic rules not supported on older versions (#103346) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f1fc4777467..ed8649896dd 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==64"], + "requirements": ["aiounifi==65"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index b5386244ee0..c005a86cac3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -369,7 +369,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==64 +aiounifi==65 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0dc4588022..b41909be078 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -344,7 +344,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==64 +aiounifi==65 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 1ef460cffe601d63e6df1c0e168b253e5b301df7 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Sat, 4 Nov 2023 12:43:20 +0400 Subject: [PATCH 117/643] Fix sensor unique id in Islamic prayer times (#103356) --- homeassistant/components/islamic_prayer_times/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index ee3c5d9071d..45270863f01 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -78,7 +78,7 @@ class IslamicPrayerTimeSensor( """Initialize the Islamic prayer time sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = description.key + self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name=NAME, From 9eff9ee374895bb055cee2640a4f34fa9e4156b7 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 3 Nov 2023 10:36:48 -0400 Subject: [PATCH 118/643] Fix zwave_js cover bug for Window Covering CC values (#103289) * Fix cover bug for Window Covering CC values * update test * Fix fixture * Remove no-op line from test --- homeassistant/components/zwave_js/cover.py | 3 +- .../convert_device_diagnostics_to_fixture.py | 10 +- .../fixtures/cover_iblinds_v3_state.json | 708 ++++++++---------- .../zwave_js/fixtures/zooz_zse44_state.json | 268 +++---- ...t_convert_device_diagnostics_to_fixture.py | 5 +- tests/components/zwave_js/test_cover.py | 12 +- 6 files changed, 459 insertions(+), 547 deletions(-) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 9a8cb203c05..364eafd8caf 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -18,7 +18,6 @@ from zwave_js_server.const.command_class.multilevel_switch import ( from zwave_js_server.const.command_class.window_covering import ( NO_POSITION_PROPERTY_KEYS, NO_POSITION_SUFFIX, - WINDOW_COVERING_OPEN_PROPERTY, SlatStates, ) from zwave_js_server.model.driver import Driver @@ -370,7 +369,7 @@ class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): set_values_func( value, stop_value=self.get_zwave_value( - WINDOW_COVERING_OPEN_PROPERTY, + "levelChangeUp", value_property_key=value.property_key, ), ) diff --git a/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py index 1e8d295227f..826f3eebe0c 100644 --- a/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py +++ b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py @@ -60,10 +60,12 @@ def extract_fixture_data(diagnostics_data: Any) -> dict: ): raise ValueError("Invalid diagnostics file format") state: dict = diagnostics_data["data"]["state"] - if isinstance(state["values"], list): - return state - values_dict: dict[str, dict] = state.pop("values") - state["values"] = list(values_dict.values()) + if not isinstance(state["values"], list): + values_dict: dict[str, dict] = state.pop("values") + state["values"] = list(values_dict.values()) + if not isinstance(state["endpoints"], list): + endpoints_dict: dict[str, dict] = state.pop("endpoints") + state["endpoints"] = list(endpoints_dict.values()) return state diff --git a/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json b/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json index f0da41e4b6f..cf04e885d79 100644 --- a/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json +++ b/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json @@ -1,5 +1,5 @@ { - "nodeId": 12, + "nodeId": 131, "index": 0, "installerIcon": 6656, "userIcon": 6656, @@ -7,12 +7,13 @@ "ready": true, "isListening": false, "isRouting": true, - "isSecure": true, + "isSecure": false, "manufacturerId": 647, "productId": 114, "productType": 4, "firmwareVersion": "3.12.1", "zwavePlusVersion": 2, + "name": "Blind West Bed 1", "deviceConfig": { "filename": "/data/db/devices/0x0287/iblindsv3.json", "isEmbedded": true, @@ -38,321 +39,61 @@ "associations": {}, "paramInformation": { "_map": {} + }, + "compat": { + "removeCCs": {} } }, "label": "iblinds V3", "interviewAttempts": 1, - "endpoints": [ - { - "nodeId": 12, - "index": 0, - "installerIcon": 6656, - "userIcon": 6656, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 17, - "label": "Multilevel Switch" - }, - "specific": { - "key": 7, - "label": "Motor Control Class C" - }, - "mandatorySupportedCCs": [32, 38, 37, 114, 134], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 38, - "name": "Multilevel Switch", - "version": 4, - "isSecure": true - }, - { - "id": 37, - "name": "Binary Switch", - "version": 2, - "isSecure": true - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 2, - "isSecure": true - }, - { - "id": 134, - "name": "Version", - "version": 3, - "isSecure": true - }, - { - "id": 94, - "name": "Z-Wave Plus Info", - "version": 2, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 2, - "isSecure": true - }, - { - "id": 89, - "name": "Association Group Information", - "version": 3, - "isSecure": true - }, - { - "id": 85, - "name": "Transport Service", - "version": 2, - "isSecure": false - }, - { - "id": 90, - "name": "Device Reset Locally", - "version": 1, - "isSecure": true - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": true - }, - { - "id": 159, - "name": "Security 2", - "version": 1, - "isSecure": true - }, - { - "id": 108, - "name": "Supervision", - "version": 1, - "isSecure": false - }, - { - "id": 122, - "name": "Firmware Update Meta Data", - "version": 5, - "isSecure": true - }, - { - "id": 128, - "name": "Battery", - "version": 1, - "isSecure": true - }, - { - "id": 112, - "name": "Configuration", - "version": 4, - "isSecure": true - }, - { - "id": 135, - "name": "Indicator", - "version": 3, - "isSecure": true - }, - { - "id": 142, - "name": "Multi Channel Association", - "version": 3, - "isSecure": true - }, - { - "id": 106, - "name": "Window Covering", - "version": 1, - "isSecure": true - }, - { - "id": 152, - "name": "Security", - "version": 1, - "isSecure": true - } - ] + "isFrequentListening": "1000ms", + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 7, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0287:0x0004:0x0072:3.12.1", + "statistics": { + "commandsTX": 95, + "commandsRX": 110, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 1295.6, + "lastSeen": "2023-11-02T18:41:40.552Z", + "rssi": -69, + "lwr": { + "protocolDataRate": 2, + "repeaters": [], + "rssi": -71, + "repeaterRSSI": [] } - ], + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2023-11-02T18:41:40.552Z", "values": [ - { - "endpoint": 0, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 2, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 0, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 2, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 0, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "duration", - "propertyName": "duration", - "ccVersion": 2, - "metadata": { - "type": "duration", - "readable": true, - "writeable": false, - "label": "Remaining duration", - "stateful": true, - "secret": false - }, - "value": { - "value": 0, - "unit": "seconds" - } - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "min": 0, - "max": 99, - "stateful": true, - "secret": false - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "duration", - "propertyName": "duration", - "ccVersion": 4, - "metadata": { - "type": "duration", - "readable": true, - "writeable": false, - "label": "Remaining duration", - "stateful": true, - "secret": false - }, - "value": { - "value": 0, - "unit": "seconds" - } - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 4, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Current value", - "min": 0, - "max": 99, - "stateful": true, - "secret": false - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "Up", - "propertyName": "Up", - "ccVersion": 4, - "metadata": { - "type": "boolean", - "readable": false, - "writeable": true, - "label": "Perform a level change (Up)", - "ccSpecific": { - "switchType": 2 - }, - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "Down", - "propertyName": "Down", - "ccVersion": 4, - "metadata": { - "type": "boolean", - "readable": false, - "writeable": true, - "label": "Perform a level change (Down)", - "ccSpecific": { - "switchType": 2 - }, - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 0, - "commandClass": 38, - "commandClassName": "Multilevel Switch", - "property": "restorePrevious", - "propertyName": "restorePrevious", - "ccVersion": 4, - "metadata": { - "type": "boolean", - "readable": false, - "writeable": true, - "label": "Restore previous value", - "stateful": true, - "secret": false - } - }, { "endpoint": 0, "commandClass": 106, @@ -361,7 +102,7 @@ "propertyKey": 23, "propertyName": "currentValue", "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, + "ccVersion": 1, "metadata": { "type": "number", "readable": true, @@ -373,9 +114,9 @@ "min": 0, "max": 99, "states": { - "0": "Closed (up)", + "0": "Closed (up inside)", "50": "Open", - "99": "Closed (down)" + "99": "Closed (down inside)" }, "stateful": true, "secret": false @@ -390,7 +131,7 @@ "propertyKey": 23, "propertyName": "targetValue", "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, + "ccVersion": 1, "metadata": { "type": "number", "readable": true, @@ -403,14 +144,14 @@ "min": 0, "max": 99, "states": { - "0": "Closed (up)", + "0": "Closed (up inside)", "50": "Open", - "99": "Closed (down)" + "99": "Closed (down inside)" }, "stateful": true, "secret": false }, - "value": 99 + "value": 0 }, { "endpoint": 0, @@ -420,7 +161,7 @@ "propertyKey": 23, "propertyName": "duration", "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, + "ccVersion": 1, "metadata": { "type": "duration", "readable": true, @@ -441,44 +182,24 @@ "endpoint": 0, "commandClass": 106, "commandClassName": "Window Covering", - "property": "open", + "property": "levelChangeUp", "propertyKey": 23, - "propertyName": "open", + "propertyName": "levelChangeUp", "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, + "ccVersion": 1, "metadata": { "type": "boolean", "readable": false, "writeable": true, - "label": "Open - Horizontal Slats Angle", + "label": "Change tilt (down inside) - Horizontal Slats Angle", "ccSpecific": { "parameter": 23 }, "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "nodeId": 12, - "value": true - }, - { - "endpoint": 0, - "commandClass": 106, - "commandClassName": "Window Covering", - "property": "close0", - "propertyKey": 23, - "propertyName": "close0", - "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, - "metadata": { - "type": "boolean", - "readable": false, - "writeable": true, - "label": "Close Up - Horizontal Slats Angle", - "ccSpecific": { - "parameter": 23 + "states": { + "true": "Start", + "false": "Stop" }, - "valueChangeOptions": ["transitionDuration"], "stateful": true, "secret": false } @@ -487,25 +208,27 @@ "endpoint": 0, "commandClass": 106, "commandClassName": "Window Covering", - "property": "close99", + "property": "levelChangeDown", "propertyKey": 23, - "propertyName": "close99", + "propertyName": "levelChangeDown", "propertyKeyName": "Horizontal Slats Angle", - "ccVersion": 0, + "ccVersion": 1, "metadata": { "type": "boolean", "readable": false, "writeable": true, - "label": "Close Down - Horizontal Slats Angle", + "label": "Change tilt (up inside) - Horizontal Slats Angle", "ccSpecific": { "parameter": 23 }, "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, "stateful": true, "secret": false - }, - "nodeId": 12, - "value": true + } }, { "endpoint": 0, @@ -604,7 +327,7 @@ "allowManualEntry": true, "isFromConfig": true }, - "value": 50 + "value": 45 }, { "endpoint": 0, @@ -656,6 +379,32 @@ }, "value": 0 }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "MC", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "MC", + "label": "MC", + "default": 1, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "noBulkSupport": true, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, { "endpoint": 0, "commandClass": 112, @@ -721,7 +470,9 @@ "format": 0, "allowManualEntry": true, "isFromConfig": true - } + }, + "nodeId": 131, + "value": 99 }, { "endpoint": 0, @@ -1169,7 +920,9 @@ "max": 255, "stateful": true, "secret": false - } + }, + "nodeId": 131, + "value": 47 }, { "endpoint": 0, @@ -1183,54 +936,209 @@ "readable": false, "writeable": true, "label": "Identify", + "states": { + "true": "Identify" + }, "stateful": true, "secret": false } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + } + }, + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "property": "targetValue", + "endpoint": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "propertyName": "targetValue", + "nodeId": 131, + "value": 45 + }, + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "property": "duration", + "endpoint": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "propertyName": "duration", + "nodeId": 131, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "property": "currentValue", + "endpoint": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "propertyName": "currentValue", + "nodeId": 131, + "value": 45 } ], - "isFrequentListening": "1000ms", - "maxDataRate": 100000, - "supportedDataRates": [40000, 100000], - "protocolVersion": 3, - "supportsBeaming": true, - "supportsSecurity": false, - "nodeType": 1, - "zwavePlusNodeType": 0, - "zwavePlusRoleType": 7, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 17, - "label": "Multilevel Switch" - }, - "specific": { - "key": 7, - "label": "Motor Control Class C" - }, - "mandatorySupportedCCs": [32, 38, 37, 114, 134], - "mandatoryControlledCCs": [] - }, - "interviewStage": "Complete", - "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0287:0x0004:0x0072:3.12.1", - "statistics": { - "commandsTX": 109, - "commandsRX": 101, - "commandsDroppedRX": 2, - "commandsDroppedTX": 0, - "timeoutResponse": 8, - "rtt": 1217.2, - "rssi": -43, - "lwr": { - "protocolDataRate": 2, - "repeaters": [], - "rssi": -45, - "repeaterRSSI": [] + "endpoints": [ + { + "nodeId": 131, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": false + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": false + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 106, + "name": "Window Covering", + "version": 1, + "isSecure": false + } + ] } - }, - "highestSecurityClass": 1, - "isControllerNode": false, - "keepAwake": false + ] } diff --git a/tests/components/zwave_js/fixtures/zooz_zse44_state.json b/tests/components/zwave_js/fixtures/zooz_zse44_state.json index a2fb5421fb7..982708aaa11 100644 --- a/tests/components/zwave_js/fixtures/zooz_zse44_state.json +++ b/tests/components/zwave_js/fixtures/zooz_zse44_state.json @@ -88,140 +88,6 @@ "isControllerNode": false, "keepAwake": false, "lastSeen": "2023-08-09T13:26:05.031Z", - "endpoints": { - "0": { - "nodeId": 23, - "index": 0, - "installerIcon": 3327, - "userIcon": 3327, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 7, - "label": "Notification Sensor" - }, - "specific": { - "key": 1, - "label": "Notification Sensor" - }, - "mandatorySupportedCCs": [], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 94, - "name": "Z-Wave Plus Info", - "version": 2, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 3, - "isSecure": true - }, - { - "id": 142, - "name": "Multi Channel Association", - "version": 4, - "isSecure": true - }, - { - "id": 89, - "name": "Association Group Information", - "version": 3, - "isSecure": true - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 11, - "isSecure": true - }, - { - "id": 85, - "name": "Transport Service", - "version": 2, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 3, - "isSecure": true - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 2, - "isSecure": true - }, - { - "id": 90, - "name": "Device Reset Locally", - "version": 1, - "isSecure": true - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": true - }, - { - "id": 128, - "name": "Battery", - "version": 3, - "isSecure": true - }, - { - "id": 159, - "name": "Security 2", - "version": 1, - "isSecure": true - }, - { - "id": 113, - "name": "Notification", - "version": 8, - "isSecure": true - }, - { - "id": 135, - "name": "Indicator", - "version": 4, - "isSecure": true - }, - { - "id": 112, - "name": "Configuration", - "version": 4, - "isSecure": true - }, - { - "id": 132, - "name": "Wake Up", - "version": 1, - "isSecure": true - }, - { - "id": 108, - "name": "Supervision", - "version": 2, - "isSecure": false - }, - { - "id": 122, - "name": "Firmware Update Meta Data", - "version": 7, - "isSecure": true - } - ] - } - }, "values": [ { "endpoint": 0, @@ -1326,5 +1192,139 @@ }, "value": 0 } + ], + "endpoints": [ + { + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 4, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 3, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 4, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 7, + "isSecure": true + } + ] + } ] } diff --git a/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py index d1e12e7abb4..ee03d57f4c7 100644 --- a/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py +++ b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py @@ -36,6 +36,10 @@ def test_fixture_functions() -> None: old_diagnostics_format_data["data"]["state"]["values"] = list( old_diagnostics_format_data["data"]["state"]["values"].values() ) + old_diagnostics_format_data["data"]["state"]["endpoints"] = list( + old_diagnostics_format_data["data"]["state"]["endpoints"].values() + ) + assert ( extract_fixture_data(old_diagnostics_format_data) == old_diagnostics_format_data["data"]["state"] @@ -54,7 +58,6 @@ def test_load_file() -> None: def test_main(capfd: pytest.CaptureFixture[str]) -> None: """Test main function.""" - Path(__file__).parents[1] / "fixtures" / "zooz_zse44_state.json" fixture_str = load_fixture("zwave_js/zooz_zse44_state.json") fixture_dict = json.loads(fixture_str) diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index fc593de883b..54be2b43765 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -829,7 +829,7 @@ async def test_iblinds_v3_cover( hass: HomeAssistant, client, iblinds_v3, integration ) -> None: """Test iBlinds v3 cover which uses Window Covering CC.""" - entity_id = "cover.window_blind_controller_horizontal_slats_angle" + entity_id = "cover.blind_west_bed_1_horizontal_slats_angle" state = hass.states.get(entity_id) assert state # This device has no state because there is no position value @@ -854,7 +854,7 @@ async def test_iblinds_v3_cover( assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" - assert args["nodeId"] == 12 + assert args["nodeId"] == 131 assert args["valueId"] == { "endpoint": 0, "commandClass": 106, @@ -875,7 +875,7 @@ async def test_iblinds_v3_cover( assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" - assert args["nodeId"] == 12 + assert args["nodeId"] == 131 assert args["valueId"] == { "endpoint": 0, "commandClass": 106, @@ -896,7 +896,7 @@ async def test_iblinds_v3_cover( assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" - assert args["nodeId"] == 12 + assert args["nodeId"] == 131 assert args["valueId"] == { "endpoint": 0, "commandClass": 106, @@ -917,11 +917,11 @@ async def test_iblinds_v3_cover( assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" - assert args["nodeId"] == 12 + assert args["nodeId"] == 131 assert args["valueId"] == { "endpoint": 0, "commandClass": 106, - "property": "open", + "property": "levelChangeUp", "propertyKey": 23, } assert args["value"] is False From b0bb91ec08918636a6bf693489890cc963992915 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 4 Nov 2023 17:08:06 +0000 Subject: [PATCH 119/643] Don't assume that the `sleep` value is a dictionary in Tractive integration (#103138) * Sleep value can be null * Catch TypeError --- homeassistant/components/tractive/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index f2853e0032c..300d7ebafc7 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -240,7 +240,7 @@ class TractiveClient: self._config_entry.data[CONF_EMAIL], ) return - except KeyError as error: + except (KeyError, TypeError) as error: _LOGGER.error("Error while listening for events: %s", error) continue except aiotractive.exceptions.TractiveError: @@ -284,11 +284,16 @@ class TractiveClient: ) def _send_wellness_update(self, event: dict[str, Any]) -> None: + sleep_day = None + sleep_night = None + if isinstance(event["sleep"], dict): + sleep_day = event["sleep"]["minutes_day_sleep"] + sleep_night = event["sleep"]["minutes_night_sleep"] payload = { ATTR_ACTIVITY_LABEL: event["wellness"].get("activity_label"), ATTR_CALORIES: event["activity"]["calories"], - ATTR_MINUTES_DAY_SLEEP: event["sleep"]["minutes_day_sleep"], - ATTR_MINUTES_NIGHT_SLEEP: event["sleep"]["minutes_night_sleep"], + ATTR_MINUTES_DAY_SLEEP: sleep_day, + ATTR_MINUTES_NIGHT_SLEEP: sleep_night, ATTR_MINUTES_REST: event["activity"]["minutes_rest"], ATTR_SLEEP_LABEL: event["wellness"].get("sleep_label"), } From aa623cc15c51a2004cc35a9b323d37335072bfc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 2 Nov 2023 20:53:26 +0100 Subject: [PATCH 120/643] Update aioairzone-cloud to v0.3.2 (#103258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index eb959342122..bbc8a84a3dc 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.1"] + "requirements": ["aioairzone-cloud==0.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c005a86cac3..0e9c7378b8d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.1 +aioairzone-cloud==0.3.2 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b41909be078..ce384b2e248 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.1 +aioairzone-cloud==0.3.2 # homeassistant.components.airzone aioairzone==0.6.9 From 2b36befe959e8843173a792ab8e252cf7db7f8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 5 Nov 2023 14:09:29 +0100 Subject: [PATCH 121/643] Update aioairzone-cloud to v0.3.5 (#103315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update aioairzone-cloud to v0.3.3 Signed-off-by: Álvaro Fernández Rojas * Update aioairzone-cloud to v0.3.4 Reverts accidental TaskGroup changes. Signed-off-by: Álvaro Fernández Rojas * Update aioairzone-cloud to v0.3.5 Signed-off-by: Álvaro Fernández Rojas * Trigger Github CI --------- Signed-off-by: Álvaro Fernández Rojas Co-authored-by: J. Nick Koston --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index bbc8a84a3dc..ea22487f4a2 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.2"] + "requirements": ["aioairzone-cloud==0.3.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0e9c7378b8d..1e388942667 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.2 +aioairzone-cloud==0.3.5 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce384b2e248..62ac01261a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.2 +aioairzone-cloud==0.3.5 # homeassistant.components.airzone aioairzone==0.6.9 From 412fa4c65a301af4772b95ff5c958fce0bd0280c Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 6 Nov 2023 06:48:00 -0700 Subject: [PATCH 122/643] Handle null data in WeatherFlow sensors (#103349) Co-authored-by: J. Nick Koston --- homeassistant/components/weatherflow/sensor.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index bc5d38e99e5..f3e5b8744e6 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -71,7 +71,8 @@ class WeatherFlowSensorEntityDescription( def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType: """Return the parsed sensor value.""" - raw_sensor_data = getattr(device, self.key) + if (raw_sensor_data := getattr(device, self.key)) is None: + return None return self.raw_data_conv_fn(raw_sensor_data) @@ -371,14 +372,17 @@ class WeatherFlowSensorEntity(SensorEntity): return self.device.last_report return None - @property - def native_value(self) -> datetime | StateType: - """Return the state of the sensor.""" - return self.entity_description.get_native_value(self.device) + def _async_update_state(self) -> None: + """Update entity state.""" + value = self.entity_description.get_native_value(self.device) + self._attr_available = value is not None + self._attr_native_value = value + self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to events.""" + self._async_update_state() for event in self.entity_description.event_subscriptions: self.async_on_remove( - self.device.on(event, lambda _: self.async_write_ha_state()) + self.device.on(event, lambda _: self._async_update_state()) ) From 334a02bc2be14ab45acf3534d8141f78dd3e45cf Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Sun, 5 Nov 2023 19:11:50 -0800 Subject: [PATCH 123/643] Handle smarttub sensor values being None (#103385) * [smarttub] handle sensor values being None * empty commit to rerun CI * lint * use const in test * reorder checks * use None instead of STATE_UNKNOWN * empty commit to rerun CI * check for STATE_UNKNOWN in test * empty commit to rerun CI --- .../components/smarttub/manifest.json | 2 +- homeassistant/components/smarttub/sensor.py | 6 ++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smarttub/test_sensor.py | 22 +++++++++++++++++++ 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index 3b8b727015b..e8db096f31d 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["smarttub"], "quality_scale": "platinum", - "requirements": ["python-smarttub==0.0.33"] + "requirements": ["python-smarttub==0.0.35"] } diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index a72555962eb..c362e1ea8f0 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -89,10 +89,14 @@ class SmartTubSensor(SmartTubSensorBase, SensorEntity): """Generic class for SmartTub status sensors.""" @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Return the current state of the sensor.""" + if self._state is None: + return None + if isinstance(self._state, Enum): return self._state.name.lower() + return self._state.lower() diff --git a/requirements_all.txt b/requirements_all.txt index 1e388942667..7baa4592aec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2184,7 +2184,7 @@ python-ripple-api==0.0.3 python-roborock==0.35.0 # homeassistant.components.smarttub -python-smarttub==0.0.33 +python-smarttub==0.0.35 # homeassistant.components.songpal python-songpal==0.15.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62ac01261a0..bfcae4f5396 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1628,7 +1628,7 @@ python-qbittorrent==0.4.3 python-roborock==0.35.0 # homeassistant.components.smarttub -python-smarttub==0.0.33 +python-smarttub==0.0.35 # homeassistant.components.songpal python-songpal==0.15.2 diff --git a/tests/components/smarttub/test_sensor.py b/tests/components/smarttub/test_sensor.py index 5c5359df381..5e476dcaaa5 100644 --- a/tests/components/smarttub/test_sensor.py +++ b/tests/components/smarttub/test_sensor.py @@ -2,6 +2,7 @@ import pytest import smarttub +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -27,6 +28,27 @@ async def test_sensor( assert state.state == expected_state +# https://github.com/home-assistant/core/issues/102339 +async def test_null_blowoutcycle( + spa, + spa_state, + config_entry, + hass: HomeAssistant, +) -> None: + """Test blowoutCycle having null value.""" + + spa_state.blowout_cycle = None + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = f"sensor.{spa.brand}_{spa.model}_blowout_cycle" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + async def test_primary_filtration( spa, spa_state, setup_entry, hass: HomeAssistant ) -> None: From 050f1085d037b6dab1b92797c799a7003c7fa6df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 18:38:28 -0500 Subject: [PATCH 124/643] Pin jaraco.functools to fix builds and CI (#103406) --- homeassistant/components/abode/manifest.json | 2 +- requirements_all.txt | 3 +++ requirements_test_all.txt | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index d27def55251..c7d51c7ea1f 100644 --- a/homeassistant/components/abode/manifest.json +++ b/homeassistant/components/abode/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_push", "loggers": ["jaraco.abode", "lomond"], - "requirements": ["jaraco.abode==3.3.0"] + "requirements": ["jaraco.abode==3.3.0", "jaraco.functools==3.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7baa4592aec..7568d76b55e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1094,6 +1094,9 @@ janus==1.0.0 # homeassistant.components.abode jaraco.abode==3.3.0 +# homeassistant.components.abode +jaraco.functools==3.9.0 + # homeassistant.components.jellyfin jellyfin-apiclient-python==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfcae4f5396..06fd96dfe82 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -862,6 +862,9 @@ janus==1.0.0 # homeassistant.components.abode jaraco.abode==3.3.0 +# homeassistant.components.abode +jaraco.functools==3.9.0 + # homeassistant.components.jellyfin jellyfin-apiclient-python==1.9.2 From 7e2c12b0a9c69dee0fe3856b8ac21e06de604233 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Nov 2023 11:12:33 +0100 Subject: [PATCH 125/643] Update tailscale to 0.6.0 (#103409) --- .../components/tailscale/diagnostics.py | 2 +- .../components/tailscale/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tailscale/conftest.py | 4 +- .../tailscale/snapshots/test_diagnostics.ambr | 87 +++++++++++++++++ .../components/tailscale/test_diagnostics.py | 95 ++----------------- 7 files changed, 99 insertions(+), 95 deletions(-) create mode 100644 tests/components/tailscale/snapshots/test_diagnostics.ambr diff --git a/homeassistant/components/tailscale/diagnostics.py b/homeassistant/components/tailscale/diagnostics.py index 0fd69a12825..687cee7741f 100644 --- a/homeassistant/components/tailscale/diagnostics.py +++ b/homeassistant/components/tailscale/diagnostics.py @@ -32,5 +32,5 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: TailscaleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] # Round-trip via JSON to trigger serialization - devices = [json.loads(device.json()) for device in coordinator.data.values()] + devices = [json.loads(device.to_json()) for device in coordinator.data.values()] return async_redact_data({"devices": devices}, TO_REDACT) diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json index 088389060f5..14f4206f44f 100644 --- a/homeassistant/components/tailscale/manifest.json +++ b/homeassistant/components/tailscale/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["tailscale==0.2.0"] + "requirements": ["tailscale==0.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7568d76b55e..b1927de3b45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2536,7 +2536,7 @@ synology-srm==0.2.0 systembridgeconnector==3.8.4 # homeassistant.components.tailscale -tailscale==0.2.0 +tailscale==0.6.0 # homeassistant.components.tank_utility tank-utility==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06fd96dfe82..a21b1a9cd70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1890,7 +1890,7 @@ switchbot-api==1.2.1 systembridgeconnector==3.8.4 # homeassistant.components.tailscale -tailscale==0.2.0 +tailscale==0.6.0 # homeassistant.components.tellduslive tellduslive==0.10.11 diff --git a/tests/components/tailscale/conftest.py b/tests/components/tailscale/conftest.py index 12f11a5656d..ec3b6afa139 100644 --- a/tests/components/tailscale/conftest.py +++ b/tests/components/tailscale/conftest.py @@ -41,7 +41,7 @@ def mock_tailscale_config_flow() -> Generator[None, MagicMock, None]: "homeassistant.components.tailscale.config_flow.Tailscale", autospec=True ) as tailscale_mock: tailscale = tailscale_mock.return_value - tailscale.devices.return_value = Devices.parse_raw( + tailscale.devices.return_value = Devices.from_json( load_fixture("tailscale/devices.json") ).devices yield tailscale @@ -54,7 +54,7 @@ def mock_tailscale(request: pytest.FixtureRequest) -> Generator[None, MagicMock, if hasattr(request, "param") and request.param: fixture = request.param - devices = Devices.parse_raw(load_fixture(fixture)).devices + devices = Devices.from_json(load_fixture(fixture)).devices with patch( "homeassistant.components.tailscale.coordinator.Tailscale", autospec=True ) as tailscale_mock: diff --git a/tests/components/tailscale/snapshots/test_diagnostics.ambr b/tests/components/tailscale/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..eba8d9bd145 --- /dev/null +++ b/tests/components/tailscale/snapshots/test_diagnostics.ambr @@ -0,0 +1,87 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'devices': list([ + dict({ + 'addresses': '**REDACTED**', + 'advertised_routes': list([ + ]), + 'authorized': True, + 'blocks_incoming_connections': False, + 'client_connectivity': dict({ + 'client_supports': dict({ + 'hair_pinning': False, + 'ipv6': False, + 'pcp': False, + 'pmp': False, + 'udp': True, + 'upnp': False, + }), + 'endpoints': '**REDACTED**', + 'mapping_varies_by_dest_ip': False, + }), + 'client_version': '1.12.3-td91ea7286-ge1bbbd90c', + 'created': '2021-08-19T09:25:22+00:00', + 'device_id': '**REDACTED**', + 'enabled_routes': list([ + ]), + 'expires': '2022-02-15T09:25:22+00:00', + 'hostname': '**REDACTED**', + 'is_external': False, + 'key_expiry_disabled': False, + 'last_seen': '2021-09-16T06:11:23+00:00', + 'machine_key': '**REDACTED**', + 'name': '**REDACTED**', + 'node_key': '**REDACTED**', + 'os': 'iOS', + 'tags': list([ + ]), + 'update_available': True, + 'user': '**REDACTED**', + }), + dict({ + 'addresses': '**REDACTED**', + 'advertised_routes': list([ + '0.0.0.0/0', + '10.10.10.0/23', + '::/0', + ]), + 'authorized': True, + 'blocks_incoming_connections': False, + 'client_connectivity': dict({ + 'client_supports': dict({ + 'hair_pinning': True, + 'ipv6': False, + 'pcp': False, + 'pmp': False, + 'udp': True, + 'upnp': False, + }), + 'endpoints': '**REDACTED**', + 'mapping_varies_by_dest_ip': False, + }), + 'client_version': '1.14.0-t5cff36945-g809e87bba', + 'created': '2021-08-29T09:49:06+00:00', + 'device_id': '**REDACTED**', + 'enabled_routes': list([ + '0.0.0.0/0', + '10.10.10.0/23', + '::/0', + ]), + 'expires': '2022-02-25T09:49:06+00:00', + 'hostname': '**REDACTED**', + 'is_external': False, + 'key_expiry_disabled': False, + 'last_seen': '2021-11-15T20:37:03+00:00', + 'machine_key': '**REDACTED**', + 'name': '**REDACTED**', + 'node_key': '**REDACTED**', + 'os': 'linux', + 'tags': list([ + ]), + 'update_available': True, + 'user': '**REDACTED**', + }), + ]), + }) +# --- diff --git a/tests/components/tailscale/test_diagnostics.py b/tests/components/tailscale/test_diagnostics.py index a6b892dbc86..4f900db7401 100644 --- a/tests/components/tailscale/test_diagnostics.py +++ b/tests/components/tailscale/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Tailscale integration.""" +from syrupy import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,93 +12,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "devices": [ - { - "addresses": REDACTED, - "device_id": REDACTED, - "user": REDACTED, - "name": REDACTED, - "hostname": REDACTED, - "client_version": "1.12.3-td91ea7286-ge1bbbd90c", - "update_available": True, - "os": "iOS", - "created": "2021-08-19T09:25:22+00:00", - "last_seen": "2021-09-16T06:11:23+00:00", - "key_expiry_disabled": False, - "expires": "2022-02-15T09:25:22+00:00", - "authorized": True, - "is_external": False, - "machine_key": REDACTED, - "node_key": REDACTED, - "blocks_incoming_connections": False, - "enabled_routes": [], - "advertised_routes": [], - "client_connectivity": { - "endpoints": REDACTED, - "derp": "", - "mapping_varies_by_dest_ip": False, - "latency": {}, - "client_supports": { - "hair_pinning": False, - "ipv6": False, - "pcp": False, - "pmp": False, - "udp": True, - "upnp": False, - }, - }, - }, - { - "addresses": REDACTED, - "device_id": REDACTED, - "user": REDACTED, - "name": REDACTED, - "hostname": REDACTED, - "client_version": "1.14.0-t5cff36945-g809e87bba", - "update_available": True, - "os": "linux", - "created": "2021-08-29T09:49:06+00:00", - "last_seen": "2021-11-15T20:37:03+00:00", - "key_expiry_disabled": False, - "expires": "2022-02-25T09:49:06+00:00", - "authorized": True, - "is_external": False, - "machine_key": REDACTED, - "node_key": REDACTED, - "blocks_incoming_connections": False, - "enabled_routes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], - "advertised_routes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], - "client_connectivity": { - "endpoints": REDACTED, - "derp": "", - "mapping_varies_by_dest_ip": False, - "latency": { - "Bangalore": {"latencyMs": 143.42505599999998}, - "Chicago": {"latencyMs": 101.123646}, - "Dallas": {"latencyMs": 136.85886}, - "Frankfurt": {"latencyMs": 18.968314}, - "London": {"preferred": True, "latencyMs": 14.314574}, - "New York City": {"latencyMs": 83.078912}, - "San Francisco": {"latencyMs": 148.215522}, - "Seattle": {"latencyMs": 181.553595}, - "Singapore": {"latencyMs": 164.566539}, - "São Paulo": {"latencyMs": 207.250179}, - "Tokyo": {"latencyMs": 226.90714300000002}, - }, - "client_supports": { - "hair_pinning": True, - "ipv6": False, - "pcp": False, - "pmp": False, - "udp": True, - "upnp": False, - }, - }, - }, - ] - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From f45114371e3513c2b6e6c555beaf2e6d416a9f1b Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Sun, 5 Nov 2023 21:13:45 +0100 Subject: [PATCH 126/643] Bump pyatmo to v7.6.0 (#103410) Signed-off-by: Tobias Sauerwein --- homeassistant/components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/netatmo/snapshots/test_diagnostics.ambr | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 7fe1f9b8c04..d031632ed75 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==7.5.0"] + "requirements": ["pyatmo==7.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b1927de3b45..0e7821f82d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1600,7 +1600,7 @@ pyairvisual==2023.08.1 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.5.0 +pyatmo==7.6.0 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a21b1a9cd70..d0bc7b85dcd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1218,7 +1218,7 @@ pyairvisual==2023.08.1 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.5.0 +pyatmo==7.6.0 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 228fc7563e0..bd9005bd389 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -572,6 +572,7 @@ 'read_smokedetector', 'read_station', 'read_thermostat', + 'read_mhs1', 'write_bubendorff', 'write_camera', 'write_magellan', @@ -579,6 +580,7 @@ 'write_presence', 'write_smarther', 'write_thermostat', + 'write_mhs1', ]), 'type': 'Bearer', }), From e56e75114a419b2013aa71afaa7b4f9d5d262a45 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 5 Nov 2023 12:41:15 +0100 Subject: [PATCH 127/643] Fix serial in Flo device information (#103427) --- homeassistant/components/flo/device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index 99e86d4b6b5..bcc52f512a1 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -139,9 +139,9 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): return self._device_information["fwVersion"] @property - def serial_number(self) -> str: + def serial_number(self) -> str | None: """Return the serial number for the device.""" - return self._device_information["serialNumber"] + return self._device_information.get("serialNumber") @property def pending_info_alerts_count(self) -> int: From 9327c51115b0e7b95debe83587ea36e11fbb6515 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 6 Nov 2023 07:52:15 +0100 Subject: [PATCH 128/643] modbus Allow swap: byte for datatype: string. (#103441) --- homeassistant/components/modbus/validators.py | 6 +++--- tests/components/modbus/test_sensor.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index bef58b3fa56..5fa314d589c 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -63,8 +63,8 @@ PARM_IS_LEGAL = namedtuple( ], ) # PARM_IS_LEGAL defines if the keywords: -# count: .. -# structure: .. +# count: +# structure: # swap: byte # swap: word # swap: word_byte (identical to swap: word) @@ -84,7 +84,7 @@ DEFAULT_STRUCT_FORMAT = { DataType.INT64: ENTRY("q", 4, PARM_IS_LEGAL(False, False, True, True, True)), DataType.UINT64: ENTRY("Q", 4, PARM_IS_LEGAL(False, False, True, True, True)), DataType.FLOAT64: ENTRY("d", 4, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.STRING: ENTRY("s", -1, PARM_IS_LEGAL(True, False, False, False, False)), + DataType.STRING: ENTRY("s", -1, PARM_IS_LEGAL(True, False, False, True, False)), DataType.CUSTOM: ENTRY("?", 0, PARM_IS_LEGAL(True, True, False, False, False)), } diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 0f79a125c86..72aebbd396f 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -513,6 +513,20 @@ async def test_config_wrong_struct_sensor( False, "07-05-2020 14:35", ), + ( + { + CONF_COUNT: 8, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_DATA_TYPE: DataType.STRING, + CONF_SWAP: CONF_SWAP_BYTE, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + }, + [0x3730, 0x302D, 0x2D35, 0x3032, 0x3032, 0x3120, 0x3A34, 0x3533], + False, + "07-05-2020 14:35", + ), ( { CONF_COUNT: 8, From 9a37868244d2ee030be0eac7cf83380b55fdde78 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 5 Nov 2023 20:20:13 +0100 Subject: [PATCH 129/643] Modbus set device_class in slaves (#103442) --- homeassistant/components/modbus/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index d7a6b4cca0f..52aa37535d6 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -168,6 +168,7 @@ class SlaveSensor( self._attr_unique_id = f"{self._attr_unique_id}_{idx}" self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = entry.get(CONF_STATE_CLASS) + self._attr_device_class = entry.get(CONF_DEVICE_CLASS) self._attr_available = False super().__init__(coordinator) From 6fd8973a0059b12d36fe0f0565efb43f86530d2b Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 6 Nov 2023 08:39:40 +0100 Subject: [PATCH 130/643] Fix KNX expose default value when attribute is `None` (#103446) Fix KNX expose default value when attribute is `null` --- homeassistant/components/knx/expose.py | 10 +++++----- tests/components/knx/test_expose.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index e14ee501d7b..d5c871d59ba 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -122,12 +122,12 @@ class KNXExposeSensor: """Extract value from state.""" if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): value = self.expose_default + elif self.expose_attribute is not None: + _attr = state.attributes.get(self.expose_attribute) + value = _attr if _attr is not None else self.expose_default else: - value = ( - state.state - if self.expose_attribute is None - else state.attributes.get(self.expose_attribute, self.expose_default) - ) + value = state.state + if self.expose_type == "binary": if value in (1, STATE_ON, "True"): return True diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index ca3fc5c7f58..4359c54164a 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -85,6 +85,14 @@ async def test_expose_attribute(hass: HomeAssistant, knx: KNXTestKit) -> None: hass.states.async_set(entity_id, "off", {}) await knx.assert_telegram_count(0) + # Change attribute; keep state + hass.states.async_set(entity_id, "on", {attribute: 1}) + await knx.assert_write("1/1/8", (1,)) + + # Change state to "off"; null attribute + hass.states.async_set(entity_id, "off", {attribute: None}) + await knx.assert_telegram_count(0) + async def test_expose_attribute_with_default( hass: HomeAssistant, knx: KNXTestKit @@ -132,6 +140,14 @@ async def test_expose_attribute_with_default( hass.states.async_set(entity_id, "off", {}) await knx.assert_write("1/1/8", (0,)) + # Change state and attribute + hass.states.async_set(entity_id, "on", {attribute: 1}) + await knx.assert_write("1/1/8", (1,)) + + # Change state to "off"; null attribute + hass.states.async_set(entity_id, "off", {attribute: None}) + await knx.assert_write("1/1/8", (0,)) + async def test_expose_string(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test an expose to send string values of up to 14 bytes only.""" From e2270a305dd37763f71e607694bde60ea76eee46 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 6 Nov 2023 07:58:13 +0100 Subject: [PATCH 131/643] Sort Withings sleep data on end date (#103454) * Sort Withings sleep data on end date * Sort Withings sleep data on end date --- .../components/withings/coordinator.py | 5 +- .../withings/fixtures/sleep_summaries.json | 78 +++++++++---------- 2 files changed, 43 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 35eeb6e62b6..7dec48a3489 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -147,7 +147,10 @@ class WithingsSleepDataUpdateCoordinator( ) if not response: return None - return response[0] + + return sorted( + response, key=lambda sleep_summary: sleep_summary.end_date, reverse=True + )[0] class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]): diff --git a/tests/components/withings/fixtures/sleep_summaries.json b/tests/components/withings/fixtures/sleep_summaries.json index 1bcfcfcc1d2..4e7f05142d3 100644 --- a/tests/components/withings/fixtures/sleep_summaries.json +++ b/tests/components/withings/fixtures/sleep_summaries.json @@ -1,43 +1,4 @@ [ - { - "id": 2081804182, - "timezone": "Europe/Paris", - "model": 32, - "model_id": 63, - "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", - "startdate": 1618691453, - "enddate": 1618713173, - "date": "2021-04-18", - "data": { - "wakeupduration": 3060, - "wakeupcount": 1, - "durationtosleep": 540, - "remsleepduration": 2400, - "durationtowakeup": 1140, - "total_sleep_time": 18660, - "sleep_efficiency": 0.86, - "sleep_latency": 540, - "wakeup_latency": 1140, - "waso": 1380, - "nb_rem_episodes": 1, - "out_of_bed_count": 0, - "lightsleepduration": 10440, - "deepsleepduration": 5820, - "hr_average": 103, - "hr_min": 70, - "hr_max": 120, - "rr_average": 14, - "rr_min": 10, - "rr_max": 20, - "breathing_disturbances_intensity": 9, - "snoring": 1080, - "snoringepisodecount": 18, - "sleep_score": 37, - "apnea_hypopnea_index": 9 - }, - "created": 1620237476, - "modified": 1620237476 - }, { "id": 2081804265, "timezone": "Europe/Paris", @@ -77,6 +38,45 @@ "created": 1620237480, "modified": 1620237479 }, + { + "id": 2081804182, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618691453, + "enddate": 1618713173, + "date": "2021-04-18", + "data": { + "wakeupduration": 3060, + "wakeupcount": 1, + "durationtosleep": 540, + "remsleepduration": 2400, + "durationtowakeup": 1140, + "total_sleep_time": 18660, + "sleep_efficiency": 0.86, + "sleep_latency": 540, + "wakeup_latency": 1140, + "waso": 1380, + "nb_rem_episodes": 1, + "out_of_bed_count": 0, + "lightsleepduration": 10440, + "deepsleepduration": 5820, + "hr_average": 103, + "hr_min": 70, + "hr_max": 120, + "rr_average": 14, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": 9, + "snoring": 1080, + "snoringepisodecount": 18, + "sleep_score": 37, + "apnea_hypopnea_index": 9 + }, + "created": 1620237476, + "modified": 1620237476 + }, { "id": 2081804358, "timezone": "Europe/Paris", From 27d8d1011e9d62fd2edfdbd657d551a978abab5e Mon Sep 17 00:00:00 2001 From: dupondje Date: Mon, 6 Nov 2023 14:19:47 +0100 Subject: [PATCH 132/643] Use right equipment identifier in DSMR setup (#103494) --- homeassistant/components/dsmr/config_flow.py | 2 + tests/components/dsmr/conftest.py | 10 +++++ tests/components/dsmr/test_config_flow.py | 44 ++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index c7b9ab4e380..3b32d354766 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -53,6 +53,8 @@ class DSMRConnection: self._protocol = protocol self._telegram: dict[str, DSMRObject] = {} self._equipment_identifier = obis_ref.EQUIPMENT_IDENTIFIER + if dsmr_version == "5B": + self._equipment_identifier = obis_ref.BELGIUM_EQUIPMENT_IDENTIFIER if dsmr_version == "5L": self._equipment_identifier = obis_ref.LUXEMBOURG_EQUIPMENT_IDENTIFIER if dsmr_version == "Q3D": diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 67e8b724a97..01aff5ae48e 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from dsmr_parser.clients.protocol import DSMRProtocol from dsmr_parser.clients.rfxtrx_protocol import RFXtrxDSMRProtocol from dsmr_parser.obis_references import ( + BELGIUM_EQUIPMENT_IDENTIFIER, EQUIPMENT_IDENTIFIER, EQUIPMENT_IDENTIFIER_GAS, LUXEMBOURG_EQUIPMENT_IDENTIFIER, @@ -81,6 +82,15 @@ async def dsmr_connection_send_validate_fixture(hass): async def connection_factory(*args, **kwargs): """Return mocked out Asyncio classes.""" + if args[1] == "5B": + protocol.telegram = { + BELGIUM_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] + ), + EQUIPMENT_IDENTIFIER_GAS: CosemObject( + EQUIPMENT_IDENTIFIER_GAS, [{"value": "123456789", "unit": ""}] + ), + } if args[1] == "5L": protocol.telegram = { LUXEMBOURG_EQUIPMENT_IDENTIFIER: CosemObject( diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 8ad7c7214a3..c4bbe9a7086 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -215,6 +215,50 @@ async def test_setup_serial_rfxtrx( assert result["data"] == {**entry_data, **SERIAL_DATA} +@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +async def test_setup_5B( + com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture +) -> None: + """Test we can setup serial.""" + port = com_port() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"type": "Serial"}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "setup_serial" + assert result["errors"] == {} + + with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"port": port.device, "dsmr_version": "5B"}, + ) + await hass.async_block_till_done() + + entry_data = { + "port": port.device, + "dsmr_version": "5B", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": "123456789", + } + + assert result["type"] == "create_entry" + assert result["title"] == port.device + assert result["data"] == entry_data + + @patch("serial.tools.list_ports.comports", return_value=[com_port()]) async def test_setup_5L( com_mock, hass: HomeAssistant, dsmr_connection_send_validate_fixture From c17def27fc8045cb244a88a5bbc5d43770492191 Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Sun, 5 Nov 2023 09:42:07 -0800 Subject: [PATCH 133/643] Fix litterrobot test failure due to time zone dependence (#103444) * fix litterrobot test * use a date in northern hemisehpere summer --- tests/components/litterrobot/test_time.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/litterrobot/test_time.py b/tests/components/litterrobot/test_time.py index 6532f2a3bc7..53f254008e7 100644 --- a/tests/components/litterrobot/test_time.py +++ b/tests/components/litterrobot/test_time.py @@ -1,10 +1,11 @@ """Test the Litter-Robot time entity.""" from __future__ import annotations -from datetime import time +from datetime import datetime, time from unittest.mock import MagicMock from pylitterbot import LitterRobot3 +import pytest from homeassistant.components.time import DOMAIN as PLATFORM_DOMAIN from homeassistant.const import ATTR_ENTITY_ID @@ -15,6 +16,7 @@ from .conftest import setup_integration SLEEP_START_TIME_ENTITY_ID = "time.test_sleep_mode_start_time" +@pytest.mark.freeze_time(datetime(2023, 7, 1, 12)) async def test_sleep_mode_start_time( hass: HomeAssistant, mock_account: MagicMock ) -> None: From 8f684ab102d583f777f74fb6e3ea6433dc057621 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 6 Nov 2023 17:56:53 +0100 Subject: [PATCH 134/643] Revert binary_sensor part of #103210 (#103499) --- homeassistant/components/mqtt/binary_sensor.py | 10 ++-------- tests/components/mqtt/test_init.py | 9 --------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index a89fb8a22fc..7ab2e9ebf90 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -42,7 +42,6 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, - validate_sensor_entity_category, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -56,7 +55,7 @@ DEFAULT_PAYLOAD_ON = "ON" DEFAULT_FORCE_UPDATE = False CONF_EXPIRE_AFTER = "expire_after" -_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, @@ -68,12 +67,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -DISCOVERY_SCHEMA = vol.All( - validate_sensor_entity_category, - _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), -) - -PLATFORM_SCHEMA_MODERN = vol.All(validate_sensor_entity_category, _PLATFORM_SCHEMA_BASE) +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 93d73094885..8112a289e62 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2163,15 +2163,6 @@ async def test_setup_manual_mqtt_with_invalid_config( } } }, - { - mqtt.DOMAIN: { - "binary_sensor": { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", - } - } - }, ], ) @patch( From d019045199c589fb60ecacd819bcbb04767b8276 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 26 Oct 2023 20:19:31 +1300 Subject: [PATCH 135/643] ESPHome: Add suggested_area from device info (#102834) --- homeassistant/components/esphome/manager.py | 5 +++++ homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 812cf430d09..d2eca7d39f9 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -596,6 +596,10 @@ def _async_setup_device_registry( model = project_name[1] hw_version = device_info.project_version + suggested_area = None + if device_info.suggested_area: + suggested_area = device_info.suggested_area + device_registry = dr.async_get(hass) device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -606,6 +610,7 @@ def _async_setup_device_registry( model=model, sw_version=sw_version, hw_version=hw_version, + suggested_area=suggested_area, ) return device_entry.id diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 702f75b166e..8968fa7da4f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.1.0", + "aioesphomeapi==18.2.0", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 0e7821f82d9..74339e7a29b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.1.0 +aioesphomeapi==18.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0bc7b85dcd..f92b787a6ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.1.0 +aioesphomeapi==18.2.0 # homeassistant.components.flo aioflo==2021.11.0 From 3cac87cf30a7377108c985781619c278e71fede0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Nov 2023 02:56:48 -0500 Subject: [PATCH 136/643] Bump aioesphomeapi to 18.2.1 (#103156) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 8968fa7da4f..4619ffef4c5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.2.0", + "aioesphomeapi==18.2.1", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 74339e7a29b..930ade42cc4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.0 +aioesphomeapi==18.2.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f92b787a6ef..0b45c9579b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.0 +aioesphomeapi==18.2.1 # homeassistant.components.flo aioflo==2021.11.0 From dbdd9d74cf8c15a52238b1092eeaafeea0c23a58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Nov 2023 07:07:44 -0600 Subject: [PATCH 137/643] Bump bluetooth-data-tools to 0.14.0 (#103413) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.13.0...v1.14.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 06e7d34e68d..813bc900900 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.13.0", + "bluetooth-data-tools==1.14.0", "dbus-fast==2.12.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4619ffef4c5..4e8d3c8dde4 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "requirements": [ "async-interrupt==1.1.1", "aioesphomeapi==18.2.1", - "bluetooth-data-tools==1.13.0", + "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index f82b2fff62b..7996376b6ac 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.13.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.14.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index a0f7685a2ec..21543ad6788 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.13.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.14.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 91ef843a864..663461ceaa1 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.13.0"] + "requirements": ["bluetooth-data-tools==1.14.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a70bcf4524a..12b1c6a4d0a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.13.0 +bluetooth-data-tools==1.14.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.4 diff --git a/requirements_all.txt b/requirements_all.txt index 930ade42cc4..e02ddb809e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -562,7 +562,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.13.0 +bluetooth-data-tools==1.14.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b45c9579b8..28f515c826d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -476,7 +476,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.13.0 +bluetooth-data-tools==1.14.0 # homeassistant.components.bond bond-async==0.2.1 From 93a0bd351a199871353e3c8795b2f6c71fcf54b7 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 7 Nov 2023 18:04:23 -0500 Subject: [PATCH 138/643] Bump blinkpy to 0.22.3 (#103438) --- homeassistant/components/blink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 54f36ec6e2e..bb8fd4a5a51 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.22.2"] + "requirements": ["blinkpy==0.22.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index e02ddb809e2..c6c465e7f9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -539,7 +539,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.2 +blinkpy==0.22.3 # homeassistant.components.bitcoin blockchain==1.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28f515c826d..3c22bbb9a05 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -460,7 +460,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.2 +blinkpy==0.22.3 # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 From 35c0c9958d64ba5fce2af996323beea29de7f8f0 Mon Sep 17 00:00:00 2001 From: dupondje Date: Wed, 8 Nov 2023 09:13:51 +0100 Subject: [PATCH 139/643] Fix 5B Gas meter in dsmr (#103506) * Fix 5B Gas meter in dsmr In commit 1b73219 the gas meter broke for 5B. As the change can't be reverted easily without removing the peak usage sensors, we implement a workaround. The first MBUS_METER_READING2 value will contain the gas meter data just like the previous BELGIUM_5MIN_GAS_METER_READING did. But this without the need to touch dsmr_parser (version). Fixes: #103306, #103293 * Use parametrize * Apply suggestions from code review Co-authored-by: Jan Bouwhuis * Add additional tests + typo fix --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/dsmr/const.py | 3 - homeassistant/components/dsmr/sensor.py | 42 +++++-- tests/components/dsmr/test_sensor.py | 152 +++++++++++++++++++++++- 3 files changed, 179 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 7bc0247aea6..5e1a54aedc4 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -34,6 +34,3 @@ DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"} DSMR_PROTOCOL = "dsmr_protocol" RFXTRX_DSMR_PROTOCOL = "rfxtrx_dsmr_protocol" - -# Temp obis until sensors replaced by mbus variants -BELGIUM_5MIN_GAS_METER_READING = r"\d-\d:24\.2\.3.+?\r\n" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 99af30b8111..fa58bd8c5a6 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -44,7 +44,6 @@ from homeassistant.helpers.typing import StateType from homeassistant.util import Throttle from .const import ( - BELGIUM_5MIN_GAS_METER_READING, CONF_DSMR_VERSION, CONF_PRECISION, CONF_PROTOCOL, @@ -382,16 +381,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, ), - DSMRSensorEntityDescription( - key="belgium_5min_gas_meter_reading", - translation_key="gas_meter_reading", - obis_reference=BELGIUM_5MIN_GAS_METER_READING, - dsmr_versions={"5B"}, - is_gas=True, - force_update=True, - device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.TOTAL_INCREASING, - ), DSMRSensorEntityDescription( key="gas_meter_reading", translation_key="gas_meter_reading", @@ -405,6 +394,31 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ) +def add_gas_sensor_5B(telegram: dict[str, DSMRObject]) -> DSMRSensorEntityDescription: + """Return correct entity for 5B Gas meter.""" + ref = None + if obis_references.BELGIUM_MBUS1_METER_READING2 in telegram: + ref = obis_references.BELGIUM_MBUS1_METER_READING2 + elif obis_references.BELGIUM_MBUS2_METER_READING2 in telegram: + ref = obis_references.BELGIUM_MBUS2_METER_READING2 + elif obis_references.BELGIUM_MBUS3_METER_READING2 in telegram: + ref = obis_references.BELGIUM_MBUS3_METER_READING2 + elif obis_references.BELGIUM_MBUS4_METER_READING2 in telegram: + ref = obis_references.BELGIUM_MBUS4_METER_READING2 + elif ref is None: + ref = obis_references.BELGIUM_MBUS1_METER_READING2 + return DSMRSensorEntityDescription( + key="belgium_5min_gas_meter_reading", + translation_key="gas_meter_reading", + obis_reference=ref, + dsmr_versions={"5B"}, + is_gas=True, + force_update=True, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -438,6 +452,10 @@ async def async_setup_entry( return (entity_description.device_class, UNIT_CONVERSION[uom]) return (entity_description.device_class, uom) + all_sensors = SENSORS + if dsmr_version == "5B": + all_sensors += (add_gas_sensor_5B(telegram),) + entities.extend( [ DSMREntity( @@ -448,7 +466,7 @@ async def async_setup_entry( telegram, description ), # type: ignore[arg-type] ) - for description in SENSORS + for description in all_sensors if ( description.dsmr_versions is None or dsmr_version in description.dsmr_versions diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 9c8c4e6fc70..e7f0e715f59 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -8,10 +8,22 @@ import asyncio import datetime from decimal import Decimal from itertools import chain, repeat +from typing import Literal from unittest.mock import DEFAULT, MagicMock +from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_METER_READING1, + BELGIUM_MBUS1_METER_READING2, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS3_METER_READING1, + BELGIUM_MBUS3_METER_READING2, + BELGIUM_MBUS4_METER_READING1, + BELGIUM_MBUS4_METER_READING2, +) +import pytest + from homeassistant import config_entries -from homeassistant.components.dsmr.const import BELGIUM_5MIN_GAS_METER_READING from homeassistant.components.sensor import ( ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -483,6 +495,10 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No from dsmr_parser.obis_references import ( BELGIUM_CURRENT_AVERAGE_DEMAND, BELGIUM_MAXIMUM_DEMAND_MONTH, + BELGIUM_MBUS1_METER_READING2, + BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS3_METER_READING2, + BELGIUM_MBUS4_METER_READING2, ELECTRICITY_ACTIVE_TARIFF, ) from dsmr_parser.objects import CosemObject, MBusObject @@ -500,13 +516,34 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No } telegram = { - BELGIUM_5MIN_GAS_METER_READING: MBusObject( - BELGIUM_5MIN_GAS_METER_READING, + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": "m3"}, ], ), + BELGIUM_MBUS2_METER_READING2: MBusObject( + BELGIUM_MBUS2_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642214)}, + {"value": Decimal(745.696), "unit": "m3"}, + ], + ), + BELGIUM_MBUS3_METER_READING2: MBusObject( + BELGIUM_MBUS3_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642215)}, + {"value": Decimal(745.697), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_METER_READING2: MBusObject( + BELGIUM_MBUS4_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(745.698), "unit": "m3"}, + ], + ), BELGIUM_CURRENT_AVERAGE_DEMAND: CosemObject( BELGIUM_CURRENT_AVERAGE_DEMAND, [{"value": Decimal(1.75), "unit": "kW"}], @@ -577,6 +614,115 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No ) +@pytest.mark.parametrize( + ("key1", "key2", "key3", "gas_value"), + [ + ( + BELGIUM_MBUS1_METER_READING1, + BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS3_METER_READING1, + "745.696", + ), + ( + BELGIUM_MBUS1_METER_READING2, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS3_METER_READING2, + "745.695", + ), + ( + BELGIUM_MBUS4_METER_READING2, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS3_METER_READING1, + "745.695", + ), + ( + BELGIUM_MBUS4_METER_READING1, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS3_METER_READING2, + "745.697", + ), + ], +) +async def test_belgian_meter_alt( + hass: HomeAssistant, + dsmr_connection_fixture, + key1: Literal, + key2: Literal, + key3: Literal, + gas_value: str, +) -> None: + """Test if Belgian meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.objects import MBusObject + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "precision": 4, + "reconnect_interval": 30, + "serial_id": "1234", + "serial_id_gas": "5678", + } + entry_options = { + "time_between_update": 0, + } + + telegram = { + key1: MBusObject( + key1, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + key2: MBusObject( + key2, + [ + {"value": datetime.datetime.fromtimestamp(1551642214)}, + {"value": Decimal(745.696), "unit": "m3"}, + ], + ), + key3: MBusObject( + key3, + [ + {"value": datetime.datetime.fromtimestamp(1551642215)}, + {"value": Decimal(745.697), "unit": "m3"}, + ], + ), + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + # check if gas consumption is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") + assert gas_consumption.state == gas_value + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture From da1c282c1b8d937f5395793d122e3ecfcd81ac42 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Nov 2023 23:43:56 +0100 Subject: [PATCH 140/643] Fix invalid MAC in samsungtv (#103512) * Fix invalid MAC in samsungtv * Also adjust __init__ --- .../components/samsungtv/__init__.py | 4 +- .../components/samsungtv/config_flow.py | 5 +- .../components/samsungtv/test_config_flow.py | 62 ++++++++++++++++--- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index b7d400ce831..2ced868ada7 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -211,7 +211,9 @@ async def _async_create_bridge_with_updated_data( partial(getmac.get_mac_address, ip=host) ) - if mac: + if mac and mac != "none": + # Samsung sometimes returns a value of "none" for the mac address + # this should be ignored LOGGER.info("Updated mac to %s for %s", mac, host) updated_data[CONF_MAC] = mac else: diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 124dab73004..f20a79cc9e6 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -219,7 +219,10 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._title = f"{self._name} ({self._model})" self._udn = _strip_uuid(dev_info.get("udn", info["id"])) if mac := mac_from_device_info(info): - self._mac = mac + # Samsung sometimes returns a value of "none" for the mac address + # this should be ignored - but also shouldn't trigger getmac + if mac != "none": + self._mac = mac elif mac := await self.hass.async_add_executor_job( partial(getmac.get_mac_address, ip=self._host) ): diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index a70a0042fcd..0eacd63b42d 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for Samsung TV config flow.""" +from copy import deepcopy from ipaddress import ip_address from unittest.mock import ANY, AsyncMock, Mock, call, patch @@ -165,14 +166,6 @@ MOCK_DEVICE_INFO = { }, "id": "123", } -MOCK_DEVICE_INFO_2 = { - "device": { - "type": "Samsung SmartTV", - "name": "fake2_name", - "modelName": "fake2_model", - }, - "id": "345", -} AUTODETECT_LEGACY = { "name": "HomeAssistant", @@ -1968,3 +1961,56 @@ async def test_no_update_incorrect_udn_not_matching_mac_from_dhcp( assert result["step_id"] == "confirm" assert entry.data[CONF_MAC] == "aa:bb:ss:ss:dd:pp" assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" + + +@pytest.mark.usefixtures("remotews", "remoteencws_failing") +async def test_ssdp_update_mac(hass: HomeAssistant) -> None: + """Ensure that MAC address is correctly updated from SSDP.""" + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", + return_value=MOCK_DEVICE_INFO, + ): + # entry was added + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + entry = result["result"] + assert entry.data[CONF_MANUFACTURER] == DEFAULT_MANUFACTURER + assert entry.data[CONF_MODEL] == "fake_model" + assert entry.data[CONF_MAC] is None + assert entry.unique_id == "123" + + device_info = deepcopy(MOCK_DEVICE_INFO) + device_info["device"]["wifiMac"] = "none" + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", + return_value=device_info, + ): + # Updated + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == RESULT_ALREADY_CONFIGURED + + # ensure mac wasn't updated with "none" + assert entry.data[CONF_MAC] is None + assert entry.unique_id == "123" + + device_info = deepcopy(MOCK_DEVICE_INFO) + device_info["device"]["wifiMac"] = "aa:bb:cc:dd:ee:ff" + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", + return_value=device_info, + ): + # Updated + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == RESULT_ALREADY_CONFIGURED + + # ensure mac was updated with new wifiMac value + assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" + assert entry.unique_id == "123" From c8d3e377f0fcbae33aa447deef944307066fbcfe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Nov 2023 04:22:41 -0600 Subject: [PATCH 141/643] Bump aioesphomeapi to 18.2.4 (#103552) --- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 25 +++++++++++++++++-- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4e8d3c8dde4..cb1a741c447 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.2.1", + "aioesphomeapi==18.2.4", "bluetooth-data-tools==1.14.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index c6c465e7f9d..df1e8835288 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.1 +aioesphomeapi==18.2.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c22bbb9a05..7d343781c60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.2.1 +aioesphomeapi==18.2.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 4ff6b503b3c..48b0868e406 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -77,6 +77,17 @@ def mock_config_entry(hass) -> MockConfigEntry: return config_entry +class BaseMockReconnectLogic(ReconnectLogic): + """Mock ReconnectLogic.""" + + def stop_callback(self) -> None: + """Stop the reconnect logic.""" + # For the purposes of testing, we don't want to wait + # for the reconnect logic to finish trying to connect + self._cancel_connect("forced disconnect from test") + self._is_stopped = True + + @pytest.fixture def mock_device_info() -> DeviceInfo: """Return the default mocked device info.""" @@ -132,7 +143,10 @@ def mock_client(mock_device_info) -> APIClient: mock_client.address = "127.0.0.1" mock_client.api_version = APIVersion(99, 99) - with patch("homeassistant.components.esphome.APIClient", mock_client), patch( + with patch( + "homeassistant.components.esphome.manager.ReconnectLogic", + BaseMockReconnectLogic, + ), patch("homeassistant.components.esphome.APIClient", mock_client), patch( "homeassistant.components.esphome.config_flow.APIClient", mock_client ): yield mock_client @@ -234,7 +248,7 @@ async def _mock_generic_device_entry( try_connect_done = Event() - class MockReconnectLogic(ReconnectLogic): + class MockReconnectLogic(BaseMockReconnectLogic): """Mock ReconnectLogic.""" def __init__(self, *args, **kwargs): @@ -250,6 +264,13 @@ async def _mock_generic_device_entry( try_connect_done.set() return result + def stop_callback(self) -> None: + """Stop the reconnect logic.""" + # For the purposes of testing, we don't want to wait + # for the reconnect logic to finish trying to connect + self._cancel_connect("forced disconnect from test") + self._is_stopped = True + with patch( "homeassistant.components.esphome.manager.ReconnectLogic", MockReconnectLogic ): From 95d4254074f79bafe25ca2e8e7d92a0d3b469bdd Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Mon, 6 Nov 2023 18:53:44 -0500 Subject: [PATCH 142/643] Bump pyenphase to 1.14.2 (#103553) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 4cffcce2d5c..718c33d2811 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.14.1"], + "requirements": ["pyenphase==1.14.2"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index df1e8835288..74d9af9953a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1696,7 +1696,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.1 +pyenphase==1.14.2 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d343781c60..4c356309f24 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1278,7 +1278,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.1 +pyenphase==1.14.2 # homeassistant.components.everlights pyeverlights==0.1.0 From 70f0ee81c9885e1dae0eb2fbfc05515315e9f090 Mon Sep 17 00:00:00 2001 From: dupondje Date: Tue, 7 Nov 2023 10:38:37 +0100 Subject: [PATCH 143/643] Update dsmr-parser to 1.3.1 to fix parsing issues (#103572) --- homeassistant/components/dsmr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index b3f59a15b80..90fd2d6cdce 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.3.0"] + "requirements": ["dsmr-parser==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 74d9af9953a..8a6230bb9d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -701,7 +701,7 @@ dovado==0.4.1 dremel3dpy==2.1.1 # homeassistant.components.dsmr -dsmr-parser==1.3.0 +dsmr-parser==1.3.1 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c356309f24..77ab2e7e38f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -572,7 +572,7 @@ discovery30303==0.2.1 dremel3dpy==2.1.1 # homeassistant.components.dsmr -dsmr-parser==1.3.0 +dsmr-parser==1.3.1 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.6 From f9c70fd3c85859c81ccd8c0f042dd901115e5d77 Mon Sep 17 00:00:00 2001 From: suaveolent <2163625+suaveolent@users.noreply.github.com> Date: Tue, 7 Nov 2023 14:46:02 +0100 Subject: [PATCH 144/643] fix: get_devices only checks for the first type (#103583) --- homeassistant/components/lupusec/switch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index b4294216003..981a2a8633a 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -28,9 +28,10 @@ def setup_platform( data = hass.data[LUPUSEC_DOMAIN] - devices = [] + device_types = [CONST.TYPE_SWITCH] - for device in data.lupusec.get_devices(generic_type=CONST.TYPE_SWITCH): + devices = [] + for device in data.lupusec.get_devices(generic_type=device_types): devices.append(LupusecSwitch(data, device)) add_entities(devices) From d1a3a5895b4de08344140f229ff2f8c4dcb83577 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 7 Nov 2023 16:33:46 +0100 Subject: [PATCH 145/643] Raise exception when data can't be fetched in Opensky (#103596) --- homeassistant/components/opensky/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index cb9c6173694..6e60c2ec4f1 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from aiohttp import BasicAuth from python_opensky import OpenSky -from python_opensky.exceptions import OpenSkyUnauthenticatedError +from python_opensky.exceptions import OpenSkyError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), contributing_user=entry.options.get(CONF_CONTRIBUTING_USER, False), ) - except OpenSkyUnauthenticatedError as exc: + except OpenSkyError as exc: raise ConfigEntryNotReady from exc coordinator = OpenSkyDataUpdateCoordinator(hass, client) From 0ffc1bae7644a67e40fc7ec7ba33b891f4f1548f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 7 Nov 2023 22:50:09 +0100 Subject: [PATCH 146/643] Bump yt-dlp to 2023.10.13 (#103616) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 37a8a0d6773..d16439800a9 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -7,5 +7,5 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2023.9.24"] + "requirements": ["yt-dlp==2023.10.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a6230bb9d7..f48915d5629 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2782,7 +2782,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.9.24 +yt-dlp==2023.10.13 # homeassistant.components.zamg zamg==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77ab2e7e38f..ad95793bfee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2079,7 +2079,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.9.24 +yt-dlp==2023.10.13 # homeassistant.components.zamg zamg==0.3.0 From f946ed9e16409f58a8bedee158c2775056aceb8e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 10 Nov 2023 09:27:33 +0100 Subject: [PATCH 147/643] Fix Reolink DHCP IP update (#103654) --- .../components/reolink/config_flow.py | 4 ++- tests/components/reolink/conftest.py | 16 ++++++++--- tests/components/reolink/test_config_flow.py | 27 +++++++++++++++++-- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 59fbdc22747..a27c84b9593 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -113,7 +113,9 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): raise AbortFlow("already_configured") # check if the camera is reachable at the new IP - host = ReolinkHost(self.hass, existing_entry.data, existing_entry.options) + new_config = dict(existing_entry.data) + new_config[CONF_HOST] = discovery_info.ip + host = ReolinkHost(self.hass, new_config, existing_entry.options) try: await host.api.get_state("GetLocalLink") await host.api.logout() diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 25719c4cff7..3efc1e481df 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -34,8 +34,10 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None]: - """Mock reolink connection.""" +def reolink_connect_class( + mock_get_source_ip: None, +) -> Generator[MagicMock, None, None]: + """Mock reolink connection and return both the host_mock and host_mock_class.""" with patch( "homeassistant.components.reolink.host.webhook.async_register", return_value=True, @@ -65,7 +67,15 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 - yield host_mock + yield host_mock_class + + +@pytest.fixture +def reolink_connect( + reolink_connect_class: MagicMock, +) -> Generator[MagicMock, None, None]: + """Mock reolink connection.""" + return reolink_connect_class.return_value @pytest.fixture diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 1a4bf999cce..9b449d4b851 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -2,7 +2,7 @@ from datetime import timedelta import json from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, call import pytest from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkError @@ -12,6 +12,7 @@ from homeassistant.components import dhcp from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL, const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.exceptions import ReolinkWebhookException +from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -380,41 +381,47 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No @pytest.mark.parametrize( - ("last_update_success", "attr", "value", "expected"), + ("last_update_success", "attr", "value", "expected", "host_call_list"), [ ( False, None, None, TEST_HOST2, + [TEST_HOST, TEST_HOST2], ), ( True, None, None, TEST_HOST, + [TEST_HOST], ), ( False, "get_state", AsyncMock(side_effect=ReolinkError("Test error")), TEST_HOST, + [TEST_HOST, TEST_HOST2], ), ( False, "mac_address", "aa:aa:aa:aa:aa:aa", TEST_HOST, + [TEST_HOST, TEST_HOST2], ), ], ) async def test_dhcp_ip_update( hass: HomeAssistant, + reolink_connect_class: MagicMock, reolink_connect: MagicMock, last_update_success: bool, attr: str, value: Any, expected: str, + host_call_list: list[str], ) -> None: """Test dhcp discovery aborts if already configured where the IP is updated if appropriate.""" config_entry = MockConfigEntry( @@ -459,6 +466,22 @@ async def test_dhcp_ip_update( const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data ) + expected_calls = [] + for host in host_call_list: + expected_calls.append( + call( + host, + TEST_USERNAME, + TEST_PASSWORD, + port=TEST_PORT, + use_https=TEST_USE_HTTPS, + protocol=DEFAULT_PROTOCOL, + timeout=DEFAULT_TIMEOUT, + ) + ) + + assert reolink_connect_class.call_args_list == expected_calls + assert result["type"] is data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" From 8ce746972f147e3cce547fcb23ed1a7d8eb087af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Nov 2023 18:13:11 -0600 Subject: [PATCH 148/643] Incease tplink setup timeout (#103671) It can take longer than 5s to do the first update of the device especially when the device is overloaded as seen in #103668 Wait 10 seconds for the discovery since when the power strips are loaded they cannot respond in time --- homeassistant/components/tplink/__init__.py | 2 +- tests/components/tplink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index d8285cbed70..f2a1e682304 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -87,7 +87,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up TPLink from a config entry.""" host = entry.data[CONF_HOST] try: - device: SmartDevice = await Discover.discover_single(host) + device: SmartDevice = await Discover.discover_single(host, timeout=10) except SmartDeviceException as ex: raise ConfigEntryNotReady from ex diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 816251ae3bb..9006a058c57 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -202,7 +202,7 @@ def _patch_discovery(device=None, no_device=False): def _patch_single_discovery(device=None, no_device=False): - async def _discover_single(*_): + async def _discover_single(*args, **kwargs): if no_device: raise SmartDeviceException return device if device else _mocked_bulb() From 30dc05cdd729ae18171a7219ad79317c98e7a598 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 9 Nov 2023 12:44:50 +0100 Subject: [PATCH 149/643] Add name to Withings coordinator (#103692) --- homeassistant/components/withings/coordinator.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 7dec48a3489..2639ccccf7d 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -37,11 +37,15 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): _default_update_interval: timedelta | None = UPDATE_INTERVAL _last_valid_update: datetime | None = None webhooks_connected: bool = False + coordinator_name: str = "" def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" super().__init__( - hass, LOGGER, name="Withings", update_interval=self._default_update_interval + hass, + LOGGER, + name=f"Withings {self.coordinator_name}", + update_interval=self._default_update_interval, ) self._client = client self.notification_categories: set[NotificationCategory] = set() @@ -77,6 +81,8 @@ class WithingsMeasurementDataUpdateCoordinator( ): """Withings measurement coordinator.""" + coordinator_name: str = "measurements" + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" super().__init__(hass, client) @@ -109,6 +115,8 @@ class WithingsSleepDataUpdateCoordinator( ): """Withings sleep coordinator.""" + coordinator_name: str = "sleep" + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" super().__init__(hass, client) @@ -156,6 +164,7 @@ class WithingsSleepDataUpdateCoordinator( class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]): """Withings bed presence coordinator.""" + coordinator_name: str = "bed presence" in_bed: bool | None = None _default_update_interval = None @@ -181,6 +190,7 @@ class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[Non class WithingsGoalsDataUpdateCoordinator(WithingsDataUpdateCoordinator[Goals]): """Withings goals coordinator.""" + coordinator_name: str = "goals" _default_update_interval = timedelta(hours=1) def webhook_subscription_listener(self, connected: bool) -> None: @@ -197,6 +207,7 @@ class WithingsActivityDataUpdateCoordinator( ): """Withings activity coordinator.""" + coordinator_name: str = "activity" _previous_data: Activity | None = None def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: @@ -235,6 +246,7 @@ class WithingsWorkoutDataUpdateCoordinator( ): """Withings workout coordinator.""" + coordinator_name: str = "workout" _previous_data: Workout | None = None def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: From f12055875097db1de9bffd43cfcbe6effc136d70 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 10 Nov 2023 09:04:33 +0100 Subject: [PATCH 150/643] Update frontend to 20231030.2 (#103706) --- 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 6fffc0e8acd..469deab23e1 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==20231030.1"] + "requirements": ["home-assistant-frontend==20231030.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 12b1c6a4d0a..67050e43eeb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231030.1 +home-assistant-frontend==20231030.2 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f48915d5629..ab4afc4dfa9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.35 # homeassistant.components.frontend -home-assistant-frontend==20231030.1 +home-assistant-frontend==20231030.2 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad95793bfee..798a6851b62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.35 # homeassistant.components.frontend -home-assistant-frontend==20231030.1 +home-assistant-frontend==20231030.2 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From eaf711335d9675099586d68e862cec2aab7bfd72 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 10 Nov 2023 10:04:50 +0100 Subject: [PATCH 151/643] Bumped version to 2023.11.2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 99c1312dc98..479bfcbac6e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 616dd219baf..55dd7a81a37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.1" +version = "2023.11.2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From e55567176540bd6509a4e5a70459d6c6f854ce30 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Nov 2023 20:02:02 +0000 Subject: [PATCH 152/643] Bump accuweather to version 2.0.1 (#103532) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 5a5a1de2a01..307d68c4b7b 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==2.0.0"] + "requirements": ["accuweather==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab4afc4dfa9..870fcc94897 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -147,7 +147,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.0.0 +accuweather==2.0.1 # homeassistant.components.adax adax==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 798a6851b62..96a13584578 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ Tami4EdgeAPI==2.1 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.0.0 +accuweather==2.0.1 # homeassistant.components.adax adax==0.3.0 From d63d7841c38ded8d664315b153317acd13387b73 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 7 Nov 2023 14:24:34 -0800 Subject: [PATCH 153/643] Remove rainbird yaml config test fixtures (#103607) --- tests/components/rainbird/conftest.py | 27 +++-------- .../components/rainbird/test_binary_sensor.py | 23 +++++----- tests/components/rainbird/test_calendar.py | 40 ++++++++--------- tests/components/rainbird/test_config_flow.py | 20 +++------ tests/components/rainbird/test_init.py | 45 +++++++------------ tests/components/rainbird/test_number.py | 28 +++++------- tests/components/rainbird/test_sensor.py | 20 ++++++--- tests/components/rainbird/test_switch.py | 43 ++++++------------ 8 files changed, 97 insertions(+), 149 deletions(-) diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index f25bdfb1d86..6e8d58219c1 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -17,13 +16,10 @@ from homeassistant.components.rainbird.const import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse -ComponentSetup = Callable[[], Awaitable[bool]] - HOST = "example.com" URL = "http://example.com/stick" PASSWORD = "password" @@ -79,12 +75,6 @@ def platforms() -> list[Platform]: return [] -@pytest.fixture -def yaml_config() -> dict[str, Any]: - """Fixture for configuration.yaml.""" - return {} - - @pytest.fixture async def config_entry_unique_id() -> str: """Fixture for serial number used in the config entry.""" @@ -122,22 +112,15 @@ async def add_config_entry( config_entry.add_to_hass(hass) -@pytest.fixture -async def setup_integration( +@pytest.fixture(autouse=True) +def setup_platforms( hass: HomeAssistant, platforms: list[str], - yaml_config: dict[str, Any], -) -> Generator[ComponentSetup, None, None]: - """Fixture for setting up the component.""" +) -> None: + """Fixture for setting up the default platforms.""" with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): - - async def func() -> bool: - result = await async_setup_component(hass, DOMAIN, yaml_config) - await hass.async_block_till_done() - return result - - yield func + yield def rainbird_response(data: str) -> bytes: diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index 24cd1750ed4..7b9fb41ed1f 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -3,12 +3,14 @@ import pytest +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, SERIAL_NUMBER, ComponentSetup +from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, SERIAL_NUMBER +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -18,21 +20,27 @@ def platforms() -> list[Platform]: return [Platform.BINARY_SENSOR] +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> list[Platform]: + """Fixture to setup the config entry.""" + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + @pytest.mark.parametrize( ("rain_response", "expected_state"), [(RAIN_SENSOR_OFF, "off"), (RAIN_SENSOR_ON, "on")], ) async def test_rainsensor( hass: HomeAssistant, - setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, expected_state: bool, ) -> None: """Test rainsensor binary sensor.""" - assert await setup_integration() - rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") assert rainsensor is not None assert rainsensor.state == expected_state @@ -53,14 +61,10 @@ async def test_rainsensor( ) async def test_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, entity_unique_id: str, ) -> None: """Test rainsensor binary sensor.""" - - assert await setup_integration() - rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") assert rainsensor is not None assert rainsensor.attributes == { @@ -83,14 +87,11 @@ async def test_unique_id( ) async def test_no_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, ) -> None: """Test rainsensor binary sensor with no unique id.""" - assert await setup_integration() - rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") assert rainsensor is not None assert ( diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 2e486226a7b..d6c14834342 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -12,12 +12,14 @@ from aiohttp import ClientSession from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ComponentSetup, mock_response, mock_response_error +from .conftest import mock_response, mock_response_error +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse TEST_ENTITY = "calendar.rain_bird_controller" @@ -80,6 +82,15 @@ def platforms() -> list[str]: return [Platform.CALENDAR] +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> list[Platform]: + """Fixture to setup the config entry.""" + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + @pytest.fixture(autouse=True) def set_time_zone(hass: HomeAssistant): """Set the time zone for the tests.""" @@ -121,13 +132,9 @@ def get_events_fixture( @pytest.mark.freeze_time("2023-01-21 09:32:00") -async def test_get_events( - hass: HomeAssistant, setup_integration: ComponentSetup, get_events: GetEventsFn -) -> None: +async def test_get_events(hass: HomeAssistant, get_events: GetEventsFn) -> None: """Test calendar event fetching APIs.""" - assert await setup_integration() - events = await get_events("2023-01-20T00:00:00Z", "2023-02-05T00:00:00Z") assert events == [ # Monday @@ -158,31 +165,34 @@ async def test_get_events( @pytest.mark.parametrize( - ("freeze_time", "expected_state"), + ("freeze_time", "expected_state", "setup_config_entry"), [ ( datetime.datetime(2023, 1, 23, 3, 50, tzinfo=ZoneInfo("America/Regina")), "off", + None, ), ( datetime.datetime(2023, 1, 23, 4, 30, tzinfo=ZoneInfo("America/Regina")), "on", + None, ), ], ) async def test_event_state( hass: HomeAssistant, - setup_integration: ComponentSetup, get_events: GetEventsFn, freezer: FrozenDateTimeFactory, freeze_time: datetime.datetime, expected_state: str, entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test calendar upcoming event state.""" freezer.move_to(freeze_time) - assert await setup_integration() + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED state = hass.states.get(TEST_ENTITY) assert state is not None @@ -213,13 +223,10 @@ async def test_event_state( ) async def test_calendar_not_supported_by_device( hass: HomeAssistant, - setup_integration: ComponentSetup, has_entity: bool, ) -> None: """Test calendar upcoming event state.""" - assert await setup_integration() - state = hass.states.get(TEST_ENTITY) assert (state is not None) == has_entity @@ -229,7 +236,6 @@ async def test_calendar_not_supported_by_device( ) async def test_no_schedule( hass: HomeAssistant, - setup_integration: ComponentSetup, get_events: GetEventsFn, responses: list[AiohttpClientMockResponse], hass_client: Callable[..., Awaitable[ClientSession]], @@ -237,8 +243,6 @@ async def test_no_schedule( """Test calendar error when fetching the calendar.""" responses.extend([mock_response_error(HTTPStatus.BAD_GATEWAY)]) # Arbitrary error - assert await setup_integration() - state = hass.states.get(TEST_ENTITY) assert state.state == "unavailable" assert state.attributes == { @@ -260,13 +264,10 @@ async def test_no_schedule( ) async def test_program_schedule_disabled( hass: HomeAssistant, - setup_integration: ComponentSetup, get_events: GetEventsFn, ) -> None: """Test calendar when the program is disabled with no upcoming events.""" - assert await setup_integration() - events = await get_events("2023-01-20T00:00:00Z", "2023-02-05T00:00:00Z") assert events == [] @@ -286,14 +287,11 @@ async def test_program_schedule_disabled( ) async def test_no_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, get_events: GetEventsFn, entity_registry: er.EntityRegistry, ) -> None: """Test calendar entity with no unique id.""" - assert await setup_integration() - state = hass.states.get(TEST_ENTITY) assert state is not None assert state.attributes.get("friendly_name") == "Rain Bird Controller" diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index cfc4ff3b5cb..f93da8d9839 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -24,10 +24,10 @@ from .conftest import ( SERIAL_RESPONSE, URL, ZERO_SERIAL_RESPONSE, - ComponentSetup, mock_response, ) +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse @@ -129,17 +129,14 @@ async def test_controller_flow( ) async def test_multiple_config_entries( hass: HomeAssistant, - setup_integration: ComponentSetup, + config_entry: MockConfigEntry, responses: list[AiohttpClientMockResponse], config_flow_responses: list[AiohttpClientMockResponse], expected_config_entry: dict[str, Any] | None, ) -> None: """Test setting up multiple config entries that refer to different devices.""" - assert await setup_integration() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state == ConfigEntryState.LOADED + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED responses.clear() responses.extend(config_flow_responses) @@ -177,16 +174,13 @@ async def test_multiple_config_entries( ) async def test_duplicate_config_entries( hass: HomeAssistant, - setup_integration: ComponentSetup, + config_entry: MockConfigEntry, responses: list[AiohttpClientMockResponse], config_flow_responses: list[AiohttpClientMockResponse], ) -> None: """Test that a device can not be registered twice.""" - assert await setup_integration() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state == ConfigEntryState.LOADED + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED responses.clear() responses.extend(config_flow_responses) diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index f548d3aacda..7ec22b88867 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -6,31 +6,30 @@ from http import HTTPStatus import pytest -from homeassistant.components.rainbird import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from .conftest import ( CONFIG_ENTRY_DATA, MODEL_AND_VERSION_RESPONSE, - ComponentSetup, mock_response, mock_response_error, ) +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse @pytest.mark.parametrize( - ("yaml_config", "config_entry_data", "initial_response"), + ("config_entry_data", "initial_response"), [ - ({}, CONFIG_ENTRY_DATA, None), + (CONFIG_ENTRY_DATA, None), ], ids=["config_entry"], ) async def test_init_success( hass: HomeAssistant, - setup_integration: ComponentSetup, + config_entry: MockConfigEntry, responses: list[AiohttpClientMockResponse], initial_response: AiohttpClientMockResponse | None, ) -> None: @@ -38,49 +37,42 @@ async def test_init_success( if initial_response: responses.insert(0, initial_response) - assert await setup_integration() + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state == ConfigEntryState.LOADED - - await hass.config_entries.async_unload(entries[0].entry_id) + await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert entries[0].state is ConfigEntryState.NOT_LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED @pytest.mark.parametrize( - ("yaml_config", "config_entry_data", "responses", "config_entry_states"), + ("config_entry_data", "responses", "config_entry_state"), [ ( - {}, CONFIG_ENTRY_DATA, [mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)], - [ConfigEntryState.SETUP_RETRY], + ConfigEntryState.SETUP_RETRY, ), ( - {}, CONFIG_ENTRY_DATA, [mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR)], - [ConfigEntryState.SETUP_RETRY], + ConfigEntryState.SETUP_RETRY, ), ( - {}, CONFIG_ENTRY_DATA, [ mock_response(MODEL_AND_VERSION_RESPONSE), mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE), ], - [ConfigEntryState.SETUP_RETRY], + ConfigEntryState.SETUP_RETRY, ), ( - {}, CONFIG_ENTRY_DATA, [ mock_response(MODEL_AND_VERSION_RESPONSE), mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR), ], - [ConfigEntryState.SETUP_RETRY], + ConfigEntryState.SETUP_RETRY, ), ], ids=[ @@ -92,13 +84,10 @@ async def test_init_success( ) async def test_communication_failure( hass: HomeAssistant, - setup_integration: ComponentSetup, - config_entry_states: list[ConfigEntryState], + config_entry: MockConfigEntry, + config_entry_state: list[ConfigEntryState], ) -> None: """Test unable to talk to device on startup, which fails setup.""" - assert await setup_integration() - - assert [ - entry.state for entry in hass.config_entries.async_entries(DOMAIN) - ] == config_entry_states + await config_entry.async_setup(hass) + assert config_entry.state == config_entry_state diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index b3cfd56832d..0beae1f5a95 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components import number from homeassistant.components.rainbird import DOMAIN -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -17,11 +17,11 @@ from .conftest import ( RAIN_DELAY, RAIN_DELAY_OFF, SERIAL_NUMBER, - ComponentSetup, mock_response, mock_response_error, ) +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -31,20 +31,26 @@ def platforms() -> list[str]: return [Platform.NUMBER] +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> list[Platform]: + """Fixture to setup the config entry.""" + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + @pytest.mark.parametrize( ("rain_delay_response", "expected_state"), [(RAIN_DELAY, "16"), (RAIN_DELAY_OFF, "0")], ) async def test_number_values( hass: HomeAssistant, - setup_integration: ComponentSetup, expected_state: str, entity_registry: er.EntityRegistry, ) -> None: """Test number platform.""" - assert await setup_integration() - raindelay = hass.states.get("number.rain_bird_controller_rain_delay") assert raindelay is not None assert raindelay.state == expected_state @@ -74,14 +80,11 @@ async def test_number_values( ) async def test_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, entity_unique_id: str, ) -> None: """Test number platform.""" - assert await setup_integration() - raindelay = hass.states.get("number.rain_bird_controller_rain_delay") assert raindelay is not None assert ( @@ -95,15 +98,12 @@ async def test_unique_id( async def test_set_value( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[str], config_entry: ConfigEntry, ) -> None: """Test setting the rain delay number.""" - assert await setup_integration() - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)}) assert device @@ -136,7 +136,6 @@ async def test_set_value( ) async def test_set_value_error( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[str], config_entry: ConfigEntry, @@ -145,8 +144,6 @@ async def test_set_value_error( ) -> None: """Test an error while talking to the device.""" - assert await setup_integration() - aioclient_mock.mock_calls.clear() responses.append(mock_response_error(status=status)) @@ -172,13 +169,10 @@ async def test_set_value_error( ) async def test_no_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, ) -> None: """Test number platform with no unique id.""" - assert await setup_integration() - raindelay = hass.states.get("number.rain_bird_controller_rain_delay") assert raindelay is not None assert ( diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index d8fb053c0ff..00d778335c5 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -3,11 +3,14 @@ import pytest +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup +from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF + +from tests.common import MockConfigEntry @pytest.fixture @@ -16,20 +19,26 @@ def platforms() -> list[str]: return [Platform.SENSOR] +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> list[Platform]: + """Fixture to setup the config entry.""" + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + @pytest.mark.parametrize( ("rain_delay_response", "expected_state"), [(RAIN_DELAY, "16"), (RAIN_DELAY_OFF, "0")], ) async def test_sensors( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, expected_state: str, ) -> None: """Test sensor platform.""" - assert await setup_integration() - raindelay = hass.states.get("sensor.rain_bird_controller_raindelay") assert raindelay is not None assert raindelay.state == expected_state @@ -66,14 +75,11 @@ async def test_sensors( ) async def test_sensor_no_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, config_entry_unique_id: str | None, ) -> None: """Test sensor platform with no unique id.""" - assert await setup_integration() - raindelay = hass.states.get("sensor.rain_bird_controller_raindelay") assert raindelay is not None assert raindelay.attributes.get("friendly_name") == "Rain Bird Controller Raindelay" diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 31b64dded99..e2b6a99d01a 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -5,6 +5,7 @@ from http import HTTPStatus import pytest from homeassistant.components.rainbird import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -21,11 +22,11 @@ from .conftest import ( ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, ZONE_OFF_RESPONSE, - ComponentSetup, mock_response, mock_response_error, ) +from tests.common import MockConfigEntry from tests.components.switch import common as switch_common from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse @@ -36,18 +37,24 @@ def platforms() -> list[str]: return [Platform.SWITCH] +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> list[Platform]: + """Fixture to setup the config entry.""" + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + @pytest.mark.parametrize( "stations_response", [EMPTY_STATIONS_RESPONSE], ) async def test_no_zones( hass: HomeAssistant, - setup_integration: ComponentSetup, ) -> None: """Test case where listing stations returns no stations.""" - assert await setup_integration() - zone = hass.states.get("switch.rain_bird_sprinkler_1") assert zone is None @@ -58,13 +65,10 @@ async def test_no_zones( ) async def test_zones( hass: HomeAssistant, - setup_integration: ComponentSetup, entity_registry: er.EntityRegistry, ) -> None: """Test switch platform with fake data that creates 7 zones with one enabled.""" - assert await setup_integration() - zone = hass.states.get("switch.rain_bird_sprinkler_1") assert zone is not None assert zone.state == "off" @@ -110,14 +114,11 @@ async def test_zones( async def test_switch_on( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], ) -> None: """Test turning on irrigation switch.""" - assert await setup_integration() - # Initially all zones are off. Pick zone3 as an arbitrary to assert # state, then update below as a switch. zone = hass.states.get("switch.rain_bird_sprinkler_3") @@ -149,14 +150,11 @@ async def test_switch_on( ) async def test_switch_off( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], ) -> None: """Test turning off irrigation switch.""" - assert await setup_integration() - # Initially the test zone is on zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None @@ -182,15 +180,12 @@ async def test_switch_off( async def test_irrigation_service( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], api_responses: list[str], ) -> None: """Test calling the irrigation service.""" - assert await setup_integration() - zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.state == "off" @@ -219,10 +214,9 @@ async def test_irrigation_service( @pytest.mark.parametrize( - ("yaml_config", "config_entry_data"), + ("config_entry_data"), [ ( - {}, { "host": HOST, "password": PASSWORD, @@ -232,17 +226,15 @@ async def test_irrigation_service( "1": "Garden Sprinkler", "2": "Back Yard", }, - }, + } ) ], ) async def test_yaml_imported_config( hass: HomeAssistant, - setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], ) -> None: """Test a config entry that was previously imported from yaml.""" - assert await setup_integration() assert hass.states.get("switch.garden_sprinkler") assert not hass.states.get("switch.rain_bird_sprinkler_1") @@ -260,7 +252,6 @@ async def test_yaml_imported_config( ) async def test_switch_error( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], status: HTTPStatus, @@ -268,8 +259,6 @@ async def test_switch_error( ) -> None: """Test an error talking to the device.""" - assert await setup_integration() - aioclient_mock.mock_calls.clear() responses.append(mock_response_error(status=status)) @@ -292,15 +281,12 @@ async def test_switch_error( ) async def test_no_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, ) -> None: """Test an irrigation switch with no unique id.""" - assert await setup_integration() - zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3" @@ -321,7 +307,6 @@ async def test_no_unique_id( ) async def test_has_unique_id( hass: HomeAssistant, - setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, @@ -329,8 +314,6 @@ async def test_has_unique_id( ) -> None: """Test an irrigation switch with no unique id.""" - assert await setup_integration() - zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3" From 3993c14f1d389ddc552bdf303e9ffe29f60dd4b3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 11 Nov 2023 09:39:41 +0100 Subject: [PATCH 154/643] Lock Withings token refresh (#103688) Lock Withings refresh --- homeassistant/components/withings/__init__.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 496aba290ba..701f7f444cf 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -166,12 +166,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: implementation = await async_get_config_entry_implementation(hass, entry) oauth_session = OAuth2Session(hass, entry, implementation) + refresh_lock = asyncio.Lock() + async def _refresh_token() -> str: - await oauth_session.async_ensure_token_valid() - token = oauth_session.token[CONF_ACCESS_TOKEN] - if TYPE_CHECKING: - assert isinstance(token, str) - return token + async with refresh_lock: + await oauth_session.async_ensure_token_valid() + token = oauth_session.token[CONF_ACCESS_TOKEN] + if TYPE_CHECKING: + assert isinstance(token, str) + return token client.refresh_token_function = _refresh_token withings_data = WithingsData( From 6f086a27d459e8c65ef4185fc2b4c0ed31d15345 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 10 Nov 2023 20:46:15 +0000 Subject: [PATCH 155/643] Bump accuweather to version 2.1.0 (#103744) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 307d68c4b7b..b74711ccbe6 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==2.0.1"] + "requirements": ["accuweather==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 870fcc94897..48df0873e15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -147,7 +147,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.0.1 +accuweather==2.1.0 # homeassistant.components.adax adax==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96a13584578..4f2ca912a17 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ Tami4EdgeAPI==2.1 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==2.0.1 +accuweather==2.1.0 # homeassistant.components.adax adax==0.3.0 From d8a6d3e1bc28182f8aef76cf493f68a92265a2f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Nov 2023 18:36:50 +0100 Subject: [PATCH 156/643] Bump python-matter-server to 4.0.2 (#103760) Co-authored-by: Marcel van der Veldt --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../matter/fixtures/config_entry_diagnostics_redacted.json | 1 + tests/components/matter/fixtures/nodes/device_diagnostics.json | 1 + 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 6f494153a97..174ebb1cab9 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==4.0.0"] + "requirements": ["python-matter-server==4.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 48df0873e15..0be089e8784 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2150,7 +2150,7 @@ python-kasa[speedups]==0.5.4 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==4.0.0 +python-matter-server==4.0.2 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f2ca912a17..994d04dee98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1603,7 +1603,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==4.0.0 +python-matter-server==4.0.2 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 3c5b82ad5b8..8a67ef0fb63 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -14,6 +14,7 @@ "node_id": 5, "date_commissioned": "2023-01-16T21:07:57.508440", "last_interview": "2023-01-16T21:07:57.508448", + "last_subscription_attempt": 0, "interview_version": 2, "attributes": { "0/4/0": 128, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 4b834cd9090..3abecbdf66f 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -3,6 +3,7 @@ "date_commissioned": "2023-01-16T21:07:57.508440", "last_interview": "2023-01-16T21:07:57.508448", "interview_version": 2, + "last_subscription_attempt": 0, "attributes": { "0/4/0": 128, "0/4/65532": 1, From db604170bab9bb6dab4324ed33857d0fbe9759ca Mon Sep 17 00:00:00 2001 From: G-Two <7310260+G-Two@users.noreply.github.com> Date: Fri, 10 Nov 2023 15:32:10 -0500 Subject: [PATCH 157/643] Bump subarulink to 0.7.9 (#103761) --- homeassistant/components/subaru/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 0c4367c77c8..0cffe2576d1 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.8"] + "requirements": ["subarulink==0.7.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0be089e8784..85be51deb9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2515,7 +2515,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.8 +subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 994d04dee98..1c4a21c0a2a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1875,7 +1875,7 @@ stookwijzer==1.3.0 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.8 +subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 From df3e49b24f3dd720bb777c868111c1465d6109e5 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 21 Nov 2023 20:25:07 +0100 Subject: [PATCH 158/643] Fix discovery schema for Matter switches (#103762) * Fix discovery schema for matter switches * fix typo in function that generates device name * add test for switchunit --- homeassistant/components/matter/adapter.py | 4 +- homeassistant/components/matter/switch.py | 11 +- .../matter/fixtures/nodes/switch-unit.json | 119 ++++++++++++++++++ tests/components/matter/test_switch.py | 41 ++++-- 4 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/switch-unit.json diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 52b8e905b4b..2831ebe9a38 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -145,9 +145,7 @@ class MatterAdapter: get_clean_name(basic_info.nodeLabel) or get_clean_name(basic_info.productLabel) or get_clean_name(basic_info.productName) - or device_type.__name__ - if device_type - else None + or (device_type.__name__ if device_type else None) ) # handle bridged devices diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index e1fb4464b83..61922e8e8c9 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -67,7 +67,15 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSwitch, required_attributes=(clusters.OnOff.Attributes.OnOff,), - # restrict device type to prevent discovery by the wrong platform + device_type=(device_types.OnOffPlugInUnit,), + ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=SwitchEntityDescription( + key="MatterSwitch", device_class=SwitchDeviceClass.SWITCH, name=None + ), + entity_class=MatterSwitch, + required_attributes=(clusters.OnOff.Attributes.OnOff,), not_device_type=( device_types.ColorTemperatureLight, device_types.DimmableLight, @@ -76,7 +84,6 @@ DISCOVERY_SCHEMAS = [ device_types.DoorLock, device_types.ColorDimmerSwitch, device_types.DimmerSwitch, - device_types.OnOffLightSwitch, device_types.Thermostat, ), ), diff --git a/tests/components/matter/fixtures/nodes/switch-unit.json b/tests/components/matter/fixtures/nodes/switch-unit.json new file mode 100644 index 00000000000..ceed22d2524 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/switch-unit.json @@ -0,0 +1,119 @@ +{ + "node_id": 1, + "date_commissioned": "2022-11-29T21:23:48.485051", + "last_interview": "2022-11-29T21:23:48.485057", + "interview_version": 2, + "attributes": { + "0/29/0": [ + { + "deviceType": 99999, + "revision": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock SwitchUnit", + "0/40/4": 32768, + "0/40/5": "Mock SwitchUnit", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20221206", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-switch-unit", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/5/0": 0, + "1/5/1": 0, + "1/5/2": 0, + "1/5/3": false, + "1/5/4": 0, + "1/5/65532": 0, + "1/5/65533": 4, + "1/5/65528": [0, 1, 2, 3, 4, 6], + "1/5/65529": [0, 1, 2, 3, 4, 5, 6], + "1/5/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/7/0": 0, + "1/7/16": 0, + "1/7/65532": 0, + "1/7/65533": 1, + "1/7/65528": [], + "1/7/65529": [], + "1/7/65531": [0, 16, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "deviceType": 9999999, + "revision": 1 + } + ], + "1/29/1": [ + 3, 4, 5, 6, 7, 8, 15, 29, 30, 37, 47, 59, 64, 65, 69, 80, 257, 258, 259, + 512, 513, 514, 516, 768, 1024, 1026, 1027, 1028, 1029, 1030, 1283, 1284, + 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293, 1294, 2820, + 4294048773 + ], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] + }, + "available": true, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index 6fbe5d58f28..ac03d731ee1 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -14,22 +14,30 @@ from .common import ( ) -@pytest.fixture(name="switch_node") -async def switch_node_fixture( +@pytest.fixture(name="powerplug_node") +async def powerplug_node_fixture( hass: HomeAssistant, matter_client: MagicMock ) -> MatterNode: - """Fixture for a switch node.""" + """Fixture for a Powerplug node.""" return await setup_integration_with_node_fixture( hass, "on-off-plugin-unit", matter_client ) +@pytest.fixture(name="switch_unit") +async def switch_unit_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Switch Unit node.""" + return await setup_integration_with_node_fixture(hass, "switch-unit", matter_client) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_turn_on( hass: HomeAssistant, matter_client: MagicMock, - switch_node: MatterNode, + powerplug_node: MatterNode, ) -> None: """Test turning on a switch.""" state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") @@ -47,12 +55,12 @@ async def test_turn_on( assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( - node_id=switch_node.node_id, + node_id=powerplug_node.node_id, endpoint_id=1, command=clusters.OnOff.Commands.On(), ) - set_node_attribute(switch_node, 1, 6, 0, True) + set_node_attribute(powerplug_node, 1, 6, 0, True) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") @@ -65,7 +73,7 @@ async def test_turn_on( async def test_turn_off( hass: HomeAssistant, matter_client: MagicMock, - switch_node: MatterNode, + powerplug_node: MatterNode, ) -> None: """Test turning off a switch.""" state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") @@ -83,7 +91,24 @@ async def test_turn_off( assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( - node_id=switch_node.node_id, + node_id=powerplug_node.node_id, endpoint_id=1, command=clusters.OnOff.Commands.Off(), ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_switch_unit( + hass: HomeAssistant, + matter_client: MagicMock, + switch_unit: MatterNode, +) -> None: + """Test if a switch entity is discovered from any (non-light) OnOf cluster device.""" + # A switch entity should be discovered as fallback for ANY Matter device (endpoint) + # that has the OnOff cluster and does not fall into an explicit discovery schema + # by another platform (e.g. light, lock etc.). + state = hass.states.get("switch.mock_switchunit") + assert state + assert state.state == "off" + assert state.attributes["friendly_name"] == "Mock SwitchUnit" From 8a152a68d89d3992fc04812c3b20718c29ff1f86 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 13 Nov 2023 22:59:03 +0100 Subject: [PATCH 159/643] Fix raising vol.Invalid during mqtt config validation instead of ValueError (#103764) --- homeassistant/components/mqtt/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 358fa6eb675..3fa3ebfd30c 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -256,7 +256,7 @@ def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: CONF_HUMIDITY_STATE_TOPIC in config and CONF_HUMIDITY_COMMAND_TOPIC not in config ): - raise ValueError( + raise vol.Invalid( f"{CONF_HUMIDITY_STATE_TOPIC} cannot be used without" f" {CONF_HUMIDITY_COMMAND_TOPIC}" ) From fb1dfb016e5a4d7adb5a52845880a29f5724403a Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 13 Nov 2023 15:42:51 +0100 Subject: [PATCH 160/643] Fix race condition in Matter unsubscribe method (#103770) --- homeassistant/components/matter/entity.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 102e0c83b7b..7e7b7a688df 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast @@ -110,7 +111,9 @@ class MatterEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" for unsub in self._unsubscribes: - unsub() + with suppress(ValueError): + # suppress ValueError to prevent race conditions + unsub() @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: From 31ac03fe504684e4e41ed9b5b944f61153dcaf90 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 11 Nov 2023 14:24:23 +0100 Subject: [PATCH 161/643] Fix typo in calendar translation (#103789) --- homeassistant/components/calendar/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 81334c12379..20679ed09b2 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -24,7 +24,7 @@ "location": { "name": "Location" }, - "messages": { + "message": { "name": "Message" }, "start_time": { From 2a26dea5874166975fe3ce309c6b705258cf24fb Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 11 Nov 2023 23:36:30 -0800 Subject: [PATCH 162/643] Fix Rainbird unique to use a more reliable source (mac address) (#101603) * Fix rainbird unique id to use a mac address for new entries * Fix typo * Normalize mac address before using as unique id * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Update test check and remove dead code * Update all config entries to the new format * Update config entry tests for migration * Fix rainbird entity unique ids * Add test coverage for repair failure * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Remove unnecessary migration failure checks * Remove invalid config entries * Update entry when entering a different hostname for an existing host. --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/rainbird/__init__.py | 88 +++++++- .../components/rainbird/config_flow.py | 45 ++-- tests/components/rainbird/conftest.py | 51 ++++- .../components/rainbird/test_binary_sensor.py | 49 ++-- tests/components/rainbird/test_calendar.py | 16 +- tests/components/rainbird/test_config_flow.py | 79 ++++++- tests/components/rainbird/test_init.py | 212 +++++++++++++++++- tests/components/rainbird/test_number.py | 57 ++--- tests/components/rainbird/test_sensor.py | 32 ++- tests/components/rainbird/test_switch.py | 47 ++-- 10 files changed, 515 insertions(+), 161 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index a97af14f449..e7a7c1200b9 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -1,17 +1,25 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations +import logging + from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController from pyrainbird.exceptions import RainbirdApiException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_registry import async_entries_for_config_entry +from .const import CONF_SERIAL_NUMBER from .coordinator import RainbirdData +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [ Platform.SWITCH, Platform.SENSOR, @@ -36,6 +44,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], ) ) + + if not (await _async_fix_unique_id(hass, controller, entry)): + return False + if mac_address := entry.data.get(CONF_MAC): + _async_fix_entity_unique_id( + hass, + er.async_get(hass), + entry.entry_id, + format_mac(mac_address), + str(entry.data[CONF_SERIAL_NUMBER]), + ) + try: model_info = await controller.get_model_and_version() except RainbirdApiException as err: @@ -51,6 +71,72 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def _async_fix_unique_id( + hass: HomeAssistant, controller: AsyncRainbirdController, entry: ConfigEntry +) -> bool: + """Update the config entry with a unique id based on the mac address.""" + _LOGGER.debug("Checking for migration of config entry (%s)", entry.unique_id) + if not (mac_address := entry.data.get(CONF_MAC)): + try: + wifi_params = await controller.get_wifi_params() + except RainbirdApiException as err: + _LOGGER.warning("Unable to fix missing unique id: %s", err) + return True + + if (mac_address := wifi_params.mac_address) is None: + _LOGGER.warning("Unable to fix missing unique id (mac address was None)") + return True + + new_unique_id = format_mac(mac_address) + if entry.unique_id == new_unique_id and CONF_MAC in entry.data: + _LOGGER.debug("Config entry already in correct state") + return True + + entries = hass.config_entries.async_entries(DOMAIN) + for existing_entry in entries: + if existing_entry.unique_id == new_unique_id: + _LOGGER.warning( + "Unable to fix missing unique id (already exists); Removing duplicate entry" + ) + hass.async_create_background_task( + hass.config_entries.async_remove(entry.entry_id), + "Remove rainbird config entry", + ) + return False + + _LOGGER.debug("Updating unique id to %s", new_unique_id) + hass.config_entries.async_update_entry( + entry, + unique_id=new_unique_id, + data={ + **entry.data, + CONF_MAC: mac_address, + }, + ) + return True + + +def _async_fix_entity_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry_id: str, + mac_address: str, + serial_number: str, +) -> None: + """Migrate existing entity if current one can't be found and an old one exists.""" + entity_entries = async_entries_for_config_entry(entity_registry, config_entry_id) + for entity_entry in entity_entries: + unique_id = str(entity_entry.unique_id) + if unique_id.startswith(mac_address): + continue + if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: + new_unique_id = f"{mac_address}{suffix}" + _LOGGER.debug("Updating unique id from %s to %s", unique_id, new_unique_id) + entity_registry.async_update_entity( + entity_entry.entity_id, new_unique_id=new_unique_id + ) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index bf6682e7a6f..f90e13d37f3 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -11,15 +11,17 @@ from pyrainbird.async_client import ( AsyncRainbirdController, RainbirdApiException, ) +from pyrainbird.data import WifiParams import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from .const import ( ATTR_DURATION, @@ -69,7 +71,7 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): error_code: str | None = None if user_input: try: - serial_number = await self._test_connection( + serial_number, wifi_params = await self._test_connection( user_input[CONF_HOST], user_input[CONF_PASSWORD] ) except ConfigFlowError as err: @@ -77,11 +79,11 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): error_code = err.error_code else: return await self.async_finish( - serial_number, data={ CONF_HOST: user_input[CONF_HOST], CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_SERIAL_NUMBER: serial_number, + CONF_MAC: wifi_params.mac_address, }, options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, ) @@ -92,8 +94,10 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors={"base": error_code} if error_code else None, ) - async def _test_connection(self, host: str, password: str) -> str: - """Test the connection and return the device serial number. + async def _test_connection( + self, host: str, password: str + ) -> tuple[str, WifiParams]: + """Test the connection and return the device identifiers. Raises a ConfigFlowError on failure. """ @@ -106,7 +110,10 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) try: async with asyncio.timeout(TIMEOUT_SECONDS): - return await controller.get_serial_number() + return await asyncio.gather( + controller.get_serial_number(), + controller.get_wifi_params(), + ) except asyncio.TimeoutError as err: raise ConfigFlowError( f"Timeout connecting to Rain Bird controller: {str(err)}", @@ -120,18 +127,28 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_finish( self, - serial_number: str, data: dict[str, Any], options: dict[str, Any], ) -> FlowResult: """Create the config entry.""" - # Prevent devices with the same serial number. If the device does not have a serial number - # then we can at least prevent configuring the same host twice. - if serial_number: - await self.async_set_unique_id(serial_number) - self._abort_if_unique_id_configured() - else: - self._async_abort_entries_match(data) + # The integration has historically used a serial number, but not all devices + # historically had a valid one. Now the mac address is used as a unique id + # and serial is still persisted in config entry data in case it is needed + # in the future. + # Either way, also prevent configuring the same host twice. + await self.async_set_unique_id(format_mac(data[CONF_MAC])) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: data[CONF_HOST], + CONF_PASSWORD: data[CONF_PASSWORD], + } + ) + self._async_abort_entries_match( + { + CONF_HOST: data[CONF_HOST], + CONF_PASSWORD: data[CONF_PASSWORD], + } + ) return self.async_create_entry( title=data[CONF_HOST], data=data, diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 6e8d58219c1..52b98e5c6b6 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from http import HTTPStatus +import json from typing import Any from unittest.mock import patch @@ -24,6 +25,8 @@ HOST = "example.com" URL = "http://example.com/stick" PASSWORD = "password" SERIAL_NUMBER = 0x12635436566 +MAC_ADDRESS = "4C:A1:61:00:11:22" +MAC_ADDRESS_UNIQUE_ID = "4c:a1:61:00:11:22" # # Response payloads below come from pyrainbird test cases. @@ -50,6 +53,20 @@ RAIN_DELAY = "B60010" # 0x10 is 16 RAIN_DELAY_OFF = "B60000" # ACK command 0x10, Echo 0x06 ACK_ECHO = "0106" +WIFI_PARAMS_RESPONSE = { + "macAddress": MAC_ADDRESS, + "localIpAddress": "1.1.1.38", + "localNetmask": "255.255.255.0", + "localGateway": "1.1.1.1", + "rssi": -61, + "wifiSsid": "wifi-ssid-name", + "wifiPassword": "wifi-password-name", + "wifiSecurity": "wpa2-aes", + "apTimeoutNoLan": 20, + "apTimeoutIdle": 20, + "apSecurity": "unknown", + "stickVersion": "Rain Bird Stick Rev C/1.63", +} CONFIG = { @@ -62,10 +79,16 @@ CONFIG = { } } +CONFIG_ENTRY_DATA_OLD_FORMAT = { + "host": HOST, + "password": PASSWORD, + "serial_number": SERIAL_NUMBER, +} CONFIG_ENTRY_DATA = { "host": HOST, "password": PASSWORD, "serial_number": SERIAL_NUMBER, + "mac": MAC_ADDRESS, } @@ -77,14 +100,23 @@ def platforms() -> list[Platform]: @pytest.fixture async def config_entry_unique_id() -> str: - """Fixture for serial number used in the config entry.""" + """Fixture for config entry unique id.""" + return MAC_ADDRESS_UNIQUE_ID + + +@pytest.fixture +async def serial_number() -> int: + """Fixture for serial number used in the config entry data.""" return SERIAL_NUMBER @pytest.fixture -async def config_entry_data() -> dict[str, Any]: +async def config_entry_data(serial_number: int) -> dict[str, Any]: """Fixture for MockConfigEntry data.""" - return CONFIG_ENTRY_DATA + return { + **CONFIG_ENTRY_DATA, + "serial_number": serial_number, + } @pytest.fixture @@ -123,17 +155,24 @@ def setup_platforms( yield -def rainbird_response(data: str) -> bytes: +def rainbird_json_response(result: dict[str, str]) -> bytes: """Create a fake API response.""" return encryption.encrypt( - '{"jsonrpc": "2.0", "result": {"data":"%s"}, "id": 1} ' % data, + '{"jsonrpc": "2.0", "result": %s, "id": 1} ' % json.dumps(result), PASSWORD, ) +def mock_json_response(result: dict[str, str]) -> AiohttpClientMockResponse: + """Create a fake AiohttpClientMockResponse.""" + return AiohttpClientMockResponse( + "POST", URL, response=rainbird_json_response(result) + ) + + def mock_response(data: str) -> AiohttpClientMockResponse: """Create a fake AiohttpClientMockResponse.""" - return AiohttpClientMockResponse("POST", URL, response=rainbird_response(data)) + return mock_json_response({"data": data}) def mock_response_error( diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index 7b9fb41ed1f..afe18337377 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -1,6 +1,8 @@ """Tests for rainbird sensor platform.""" +from http import HTTPStatus + import pytest from homeassistant.config_entries import ConfigEntryState @@ -8,7 +10,12 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, SERIAL_NUMBER +from .conftest import ( + CONFIG_ENTRY_DATA_OLD_FORMAT, + RAIN_SENSOR_OFF, + RAIN_SENSOR_ON, + mock_response_error, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -51,47 +58,25 @@ async def test_rainsensor( @pytest.mark.parametrize( - ("config_entry_unique_id", "entity_unique_id"), + ("config_entry_data", "config_entry_unique_id", "setup_config_entry"), [ - (SERIAL_NUMBER, "1263613994342-rainsensor"), - # Some existing config entries may have a "0" serial number but preserve - # their unique id - (0, "0-rainsensor"), - ], -) -async def test_unique_id( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - entity_unique_id: str, -) -> None: - """Test rainsensor binary sensor.""" - rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") - assert rainsensor is not None - assert rainsensor.attributes == { - "friendly_name": "Rain Bird Controller Rainsensor", - "icon": "mdi:water", - } - - entity_entry = entity_registry.async_get( - "binary_sensor.rain_bird_controller_rainsensor" - ) - assert entity_entry - assert entity_entry.unique_id == entity_unique_id - - -@pytest.mark.parametrize( - ("config_entry_unique_id"), - [ - (None), + (CONFIG_ENTRY_DATA_OLD_FORMAT, None, None), ], ) async def test_no_unique_id( hass: HomeAssistant, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test rainsensor binary sensor with no unique id.""" + # Failure to migrate config entry to a unique id + responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") assert rainsensor is not None assert ( diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index d6c14834342..04e423a399c 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -17,7 +17,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import mock_response, mock_response_error +from .conftest import CONFIG_ENTRY_DATA_OLD_FORMAT, mock_response, mock_response_error from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -210,7 +210,7 @@ async def test_event_state( entity = entity_registry.async_get(TEST_ENTITY) assert entity - assert entity.unique_id == 1263613994342 + assert entity.unique_id == "4c:a1:61:00:11:22" @pytest.mark.parametrize( @@ -280,18 +280,26 @@ async def test_program_schedule_disabled( @pytest.mark.parametrize( - ("config_entry_unique_id"), + ("config_entry_data", "config_entry_unique_id", "setup_config_entry"), [ - (None), + (CONFIG_ENTRY_DATA_OLD_FORMAT, None, None), ], ) async def test_no_unique_id( hass: HomeAssistant, get_events: GetEventsFn, + responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test calendar entity with no unique id.""" + # Failure to migrate config entry to a unique id + responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + state = hass.states.get(TEST_ENTITY) assert state is not None assert state.attributes.get("friendly_name") == "Rain Bird Controller" diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index f93da8d9839..6c0e13fef39 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -19,11 +19,14 @@ from homeassistant.data_entry_flow import FlowResult, FlowResultType from .conftest import ( CONFIG_ENTRY_DATA, HOST, + MAC_ADDRESS_UNIQUE_ID, PASSWORD, SERIAL_NUMBER, SERIAL_RESPONSE, URL, + WIFI_PARAMS_RESPONSE, ZERO_SERIAL_RESPONSE, + mock_json_response, mock_response, ) @@ -34,7 +37,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockRespon @pytest.fixture(name="responses") def mock_responses() -> list[AiohttpClientMockResponse]: """Set up fake serial number response when testing the connection.""" - return [mock_response(SERIAL_RESPONSE)] + return [mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)] @pytest.fixture(autouse=True) @@ -74,14 +77,20 @@ async def complete_flow(hass: HomeAssistant) -> FlowResult: ("responses", "expected_config_entry", "expected_unique_id"), [ ( - [mock_response(SERIAL_RESPONSE)], + [ + mock_response(SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], CONFIG_ENTRY_DATA, - SERIAL_NUMBER, + MAC_ADDRESS_UNIQUE_ID, ), ( - [mock_response(ZERO_SERIAL_RESPONSE)], + [ + mock_response(ZERO_SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], {**CONFIG_ENTRY_DATA, "serial_number": 0}, - None, + MAC_ADDRESS_UNIQUE_ID, ), ], ) @@ -115,17 +124,32 @@ async def test_controller_flow( ( "other-serial-number", {**CONFIG_ENTRY_DATA, "host": "other-host"}, - [mock_response(SERIAL_RESPONSE)], + [mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)], + CONFIG_ENTRY_DATA, + ), + ( + "11:22:33:44:55:66", + { + **CONFIG_ENTRY_DATA, + "host": "other-host", + }, + [ + mock_response(SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], CONFIG_ENTRY_DATA, ), ( None, {**CONFIG_ENTRY_DATA, "serial_number": 0, "host": "other-host"}, - [mock_response(ZERO_SERIAL_RESPONSE)], + [ + mock_response(ZERO_SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], {**CONFIG_ENTRY_DATA, "serial_number": 0}, ), ], - ids=["with-serial", "zero-serial"], + ids=["with-serial", "with-mac-address", "zero-serial"], ) async def test_multiple_config_entries( hass: HomeAssistant, @@ -154,22 +178,52 @@ async def test_multiple_config_entries( "config_entry_unique_id", "config_entry_data", "config_flow_responses", + "expected_config_entry_data", ), [ + # Config entry is a pure duplicate with the same mac address unique id + ( + MAC_ADDRESS_UNIQUE_ID, + CONFIG_ENTRY_DATA, + [ + mock_response(SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], + CONFIG_ENTRY_DATA, + ), + # Old unique id with serial, but same host ( SERIAL_NUMBER, CONFIG_ENTRY_DATA, - [mock_response(SERIAL_RESPONSE)], + [mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)], + CONFIG_ENTRY_DATA, ), + # Old unique id with no serial, but same host ( None, {**CONFIG_ENTRY_DATA, "serial_number": 0}, - [mock_response(ZERO_SERIAL_RESPONSE)], + [ + mock_response(ZERO_SERIAL_RESPONSE), + mock_json_response(WIFI_PARAMS_RESPONSE), + ], + {**CONFIG_ENTRY_DATA, "serial_number": 0}, + ), + # Enters a different hostname that points to the same mac address + ( + MAC_ADDRESS_UNIQUE_ID, + { + **CONFIG_ENTRY_DATA, + "host": f"other-{HOST}", + }, + [mock_response(SERIAL_RESPONSE), mock_json_response(WIFI_PARAMS_RESPONSE)], + CONFIG_ENTRY_DATA, # Updated the host ), ], ids=[ - "duplicate-serial-number", + "duplicate-mac-unique-id", + "duplicate-host-legacy-serial-number", "duplicate-host-port-no-serial", + "duplicate-duplicate-hostname", ], ) async def test_duplicate_config_entries( @@ -177,6 +231,7 @@ async def test_duplicate_config_entries( config_entry: MockConfigEntry, responses: list[AiohttpClientMockResponse], config_flow_responses: list[AiohttpClientMockResponse], + expected_config_entry_data: dict[str, Any], ) -> None: """Test that a device can not be registered twice.""" await config_entry.async_setup(hass) @@ -186,8 +241,10 @@ async def test_duplicate_config_entries( responses.extend(config_flow_responses) result = await complete_flow(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "already_configured" + assert dict(config_entry.data) == expected_config_entry_data async def test_controller_cannot_connect( diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 7ec22b88867..db9c4c8739e 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -6,12 +6,21 @@ from http import HTTPStatus import pytest +from homeassistant.components.rainbird.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import ( CONFIG_ENTRY_DATA, + CONFIG_ENTRY_DATA_OLD_FORMAT, + MAC_ADDRESS, + MAC_ADDRESS_UNIQUE_ID, MODEL_AND_VERSION_RESPONSE, + SERIAL_NUMBER, + WIFI_PARAMS_RESPONSE, + mock_json_response, mock_response, mock_response_error, ) @@ -20,22 +29,11 @@ from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse -@pytest.mark.parametrize( - ("config_entry_data", "initial_response"), - [ - (CONFIG_ENTRY_DATA, None), - ], - ids=["config_entry"], -) async def test_init_success( hass: HomeAssistant, config_entry: MockConfigEntry, - responses: list[AiohttpClientMockResponse], - initial_response: AiohttpClientMockResponse | None, ) -> None: """Test successful setup and unload.""" - if initial_response: - responses.insert(0, initial_response) await config_entry.async_setup(hass) assert config_entry.state == ConfigEntryState.LOADED @@ -88,6 +86,196 @@ async def test_communication_failure( config_entry_state: list[ConfigEntryState], ) -> None: """Test unable to talk to device on startup, which fails setup.""" - await config_entry.async_setup(hass) assert config_entry.state == config_entry_state + + +@pytest.mark.parametrize( + ("config_entry_unique_id", "config_entry_data"), + [ + ( + None, + {**CONFIG_ENTRY_DATA, "mac": None}, + ), + ], + ids=["config_entry"], +) +async def test_fix_unique_id( + hass: HomeAssistant, + responses: list[AiohttpClientMockResponse], + config_entry: MockConfigEntry, +) -> None: + """Test fix of a config entry with no unique id.""" + + responses.insert(0, mock_json_response(WIFI_PARAMS_RESPONSE)) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.NOT_LOADED + assert entries[0].unique_id is None + assert entries[0].data.get(CONF_MAC) is None + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + # Verify config entry now has a unique id + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state == ConfigEntryState.LOADED + assert entries[0].unique_id == MAC_ADDRESS_UNIQUE_ID + assert entries[0].data.get(CONF_MAC) == MAC_ADDRESS + + +@pytest.mark.parametrize( + ( + "config_entry_unique_id", + "config_entry_data", + "initial_response", + "expected_warning", + ), + [ + ( + None, + CONFIG_ENTRY_DATA_OLD_FORMAT, + mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE), + "Unable to fix missing unique id:", + ), + ( + None, + CONFIG_ENTRY_DATA_OLD_FORMAT, + mock_response_error(HTTPStatus.NOT_FOUND), + "Unable to fix missing unique id:", + ), + ( + None, + CONFIG_ENTRY_DATA_OLD_FORMAT, + mock_response("bogus"), + "Unable to fix missing unique id (mac address was None)", + ), + ], + ids=["service_unavailable", "not_found", "unexpected_response_format"], +) +async def test_fix_unique_id_failure( + hass: HomeAssistant, + initial_response: AiohttpClientMockResponse, + responses: list[AiohttpClientMockResponse], + expected_warning: str, + caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, +) -> None: + """Test a failure during fix of a config entry with no unique id.""" + + responses.insert(0, initial_response) + + await config_entry.async_setup(hass) + # Config entry is loaded, but not updated + assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.unique_id is None + + assert expected_warning in caplog.text + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [(MAC_ADDRESS_UNIQUE_ID)], +) +async def test_fix_unique_id_duplicate( + hass: HomeAssistant, + config_entry: MockConfigEntry, + responses: list[AiohttpClientMockResponse], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a config entry unique id already exists during fix.""" + # Add a second config entry that has no unique id, but has the same + # mac address. When fixing the unique id, it can't use the mac address + # since it already exists. + other_entry = MockConfigEntry( + unique_id=None, + domain=DOMAIN, + data=CONFIG_ENTRY_DATA_OLD_FORMAT, + ) + other_entry.add_to_hass(hass) + + # Responses for the second config entry. This first fetches wifi params + # to repair the unique id. + responses_copy = [*responses] + responses.append(mock_json_response(WIFI_PARAMS_RESPONSE)) + responses.extend(responses_copy) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.unique_id == MAC_ADDRESS_UNIQUE_ID + + await other_entry.async_setup(hass) + # Config entry unique id could not be updated since it already exists + assert other_entry.state == ConfigEntryState.SETUP_ERROR + + assert "Unable to fix missing unique id (already exists)" in caplog.text + + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.parametrize( + ( + "config_entry_unique_id", + "serial_number", + "entity_unique_id", + "expected_unique_id", + ), + [ + (SERIAL_NUMBER, SERIAL_NUMBER, SERIAL_NUMBER, MAC_ADDRESS_UNIQUE_ID), + ( + SERIAL_NUMBER, + SERIAL_NUMBER, + f"{SERIAL_NUMBER}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + ), + ("0", 0, "0", MAC_ADDRESS_UNIQUE_ID), + ( + "0", + 0, + "0-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + ), + ( + MAC_ADDRESS_UNIQUE_ID, + SERIAL_NUMBER, + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + ), + ( + MAC_ADDRESS_UNIQUE_ID, + SERIAL_NUMBER, + f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", + ), + ], + ids=( + "serial-number", + "serial-number-with-suffix", + "zero-serial", + "zero-serial-suffix", + "new-format", + "new-format-suffx", + ), +) +async def test_fix_entity_unique_ids( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_unique_id: str, + expected_unique_id: str, +) -> None: + """Test fixing entity unique ids from old unique id formats.""" + + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get_or_create( + DOMAIN, "number", unique_id=entity_unique_id, config_entry=config_entry + ) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + + entity_entry = entity_registry.async_get(entity_entry.id) + assert entity_entry + assert entity_entry.unique_id == expected_unique_id diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 0beae1f5a95..79b8fd5ec37 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components import number from homeassistant.components.rainbird import DOMAIN -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -14,15 +14,16 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ( ACK_ECHO, + CONFIG_ENTRY_DATA_OLD_FORMAT, + MAC_ADDRESS, RAIN_DELAY, RAIN_DELAY_OFF, - SERIAL_NUMBER, mock_response, mock_response_error, ) from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse @pytest.fixture @@ -66,46 +67,23 @@ async def test_number_values( entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") assert entity_entry - assert entity_entry.unique_id == "1263613994342-rain-delay" - - -@pytest.mark.parametrize( - ("config_entry_unique_id", "entity_unique_id"), - [ - (SERIAL_NUMBER, "1263613994342-rain-delay"), - # Some existing config entries may have a "0" serial number but preserve - # their unique id - (0, "0-rain-delay"), - ], -) -async def test_unique_id( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - entity_unique_id: str, -) -> None: - """Test number platform.""" - - raindelay = hass.states.get("number.rain_bird_controller_rain_delay") - assert raindelay is not None - assert ( - raindelay.attributes.get("friendly_name") == "Rain Bird Controller Rain delay" - ) - - entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") - assert entity_entry - assert entity_entry.unique_id == entity_unique_id + assert entity_entry.unique_id == "4c:a1:61:00:11:22-rain-delay" async def test_set_value( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, responses: list[str], - config_entry: ConfigEntry, ) -> None: """Test setting the rain delay number.""" + raindelay = hass.states.get("number.rain_bird_controller_rain_delay") + assert raindelay is not None + device_registry = dr.async_get(hass) - device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, MAC_ADDRESS.lower())} + ) assert device assert device.name == "Rain Bird Controller" assert device.model == "ESP-TM2" @@ -138,7 +116,6 @@ async def test_set_value_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, responses: list[str], - config_entry: ConfigEntry, status: HTTPStatus, expected_msg: str, ) -> None: @@ -162,17 +139,25 @@ async def test_set_value_error( @pytest.mark.parametrize( - ("config_entry_unique_id"), + ("config_entry_data", "config_entry_unique_id", "setup_config_entry"), [ - (None), + (CONFIG_ENTRY_DATA_OLD_FORMAT, None, None), ], ) async def test_no_unique_id( hass: HomeAssistant, + responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test number platform with no unique id.""" + # Failure to migrate config entry to a unique id + responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + raindelay = hass.states.get("number.rain_bird_controller_rain_delay") assert raindelay is not None assert ( diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index 00d778335c5..2a0195f8d97 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -1,5 +1,6 @@ """Tests for rainbird sensor platform.""" +from http import HTTPStatus import pytest @@ -8,9 +9,15 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF +from .conftest import ( + CONFIG_ENTRY_DATA_OLD_FORMAT, + RAIN_DELAY, + RAIN_DELAY_OFF, + mock_response_error, +) from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMockResponse @pytest.fixture @@ -49,37 +56,38 @@ async def test_sensors( entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") assert entity_entry - assert entity_entry.unique_id == "1263613994342-raindelay" + assert entity_entry.unique_id == "4c:a1:61:00:11:22-raindelay" @pytest.mark.parametrize( - ("config_entry_unique_id", "config_entry_data"), + ("config_entry_unique_id", "config_entry_data", "setup_config_entry"), [ # Config entry setup without a unique id since it had no serial number ( None, { - **CONFIG_ENTRY_DATA, - "serial_number": 0, - }, - ), - # Legacy case for old config entries with serial number 0 preserves old behavior - ( - "0", - { - **CONFIG_ENTRY_DATA, + **CONFIG_ENTRY_DATA_OLD_FORMAT, "serial_number": 0, }, + None, ), ], ) async def test_sensor_no_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, + responses: list[AiohttpClientMockResponse], config_entry_unique_id: str | None, + config_entry: MockConfigEntry, ) -> None: """Test sensor platform with no unique id.""" + # Failure to migrate config entry to a unique id + responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED + raindelay = hass.states.get("sensor.rain_bird_controller_raindelay") assert raindelay is not None assert raindelay.attributes.get("friendly_name") == "Rain Bird Controller Raindelay" diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index e2b6a99d01a..f9c03f63dd3 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -13,12 +13,13 @@ from homeassistant.helpers import entity_registry as er from .conftest import ( ACK_ECHO, + CONFIG_ENTRY_DATA_OLD_FORMAT, EMPTY_STATIONS_RESPONSE, HOST, + MAC_ADDRESS, PASSWORD, RAIN_DELAY_OFF, RAIN_SENSOR_OFF, - SERIAL_NUMBER, ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, ZONE_OFF_RESPONSE, @@ -109,7 +110,7 @@ async def test_zones( # Verify unique id for one of the switches entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") - assert entity_entry.unique_id == "1263613994342-3" + assert entity_entry.unique_id == "4c:a1:61:00:11:22-3" async def test_switch_on( @@ -226,6 +227,7 @@ async def test_irrigation_service( "1": "Garden Sprinkler", "2": "Back Yard", }, + "mac": MAC_ADDRESS, } ) ], @@ -274,9 +276,9 @@ async def test_switch_error( @pytest.mark.parametrize( - ("config_entry_unique_id"), + ("config_entry_data", "config_entry_unique_id", "setup_config_entry"), [ - (None), + (CONFIG_ENTRY_DATA_OLD_FORMAT, None, None), ], ) async def test_no_unique_id( @@ -284,8 +286,15 @@ async def test_no_unique_id( aioclient_mock: AiohttpClientMocker, responses: list[AiohttpClientMockResponse], entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: - """Test an irrigation switch with no unique id.""" + """Test an irrigation switch with no unique id due to migration failure.""" + + # Failure to migrate config entry to a unique id + responses.insert(0, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)) + + await config_entry.async_setup(hass) + assert config_entry.state == ConfigEntryState.LOADED zone = hass.states.get("switch.rain_bird_sprinkler_3") assert zone is not None @@ -294,31 +303,3 @@ async def test_no_unique_id( entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") assert entity_entry is None - - -@pytest.mark.parametrize( - ("config_entry_unique_id", "entity_unique_id"), - [ - (SERIAL_NUMBER, "1263613994342-3"), - # Some existing config entries may have a "0" serial number but preserve - # their unique id - (0, "0-3"), - ], -) -async def test_has_unique_id( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - responses: list[AiohttpClientMockResponse], - entity_registry: er.EntityRegistry, - entity_unique_id: str, -) -> None: - """Test an irrigation switch with no unique id.""" - - zone = hass.states.get("switch.rain_bird_sprinkler_3") - assert zone is not None - assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3" - assert zone.state == "off" - - entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") - assert entity_entry - assert entity_entry.unique_id == entity_unique_id From 3dddf6b9f646bf98bf7901e1e132329b0adb3c35 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 7 Nov 2023 13:12:26 +0100 Subject: [PATCH 163/643] Bump pyOverkiz to 1.13.0 (#103582) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 3b3afddc489..f57e351a282 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.12.1"], + "requirements": ["pyoverkiz==1.13.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 85be51deb9b..64ad1b8cc63 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1935,7 +1935,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.12.1 +pyoverkiz==1.13.0 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c4a21c0a2a..d819b2005eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1460,7 +1460,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.12.1 +pyoverkiz==1.13.0 # homeassistant.components.openweathermap pyowm==3.2.0 From 149aef9a128d5bc26beb9173842181f77b0933ef Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 13 Nov 2023 15:41:06 +0100 Subject: [PATCH 164/643] Bump pyOverkiz to 1.13.2 (#103790) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index f57e351a282..cc9a410392a 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.13.0"], + "requirements": ["pyoverkiz==1.13.2"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 64ad1b8cc63..b1eca178fc4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1935,7 +1935,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.0 +pyoverkiz==1.13.2 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d819b2005eb..7b054c4eda8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1460,7 +1460,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.0 +pyoverkiz==1.13.2 # homeassistant.components.openweathermap pyowm==3.2.0 From 57c76b2ea35a55efce5ee5de437cd6c17dd0427a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 13 Nov 2023 19:09:46 +0100 Subject: [PATCH 165/643] Bump aiocomelit to 0.5.2 (#103791) * Bump aoicomelit to 0.5.0 * bump to 0.5.2 --- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 5978f17cfc4..77796ac7e7f 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.3.0"] + "requirements": ["aiocomelit==0.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b1eca178fc4..d5fa9a91e5c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -216,7 +216,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.3.0 +aiocomelit==0.5.2 # homeassistant.components.dhcp aiodiscover==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b054c4eda8..de51a342b44 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -197,7 +197,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.3.0 +aiocomelit==0.5.2 # homeassistant.components.dhcp aiodiscover==1.5.1 From 6133ce02580b5ffaad9ad2f97d0b9fdf06898e8b Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 13 Nov 2023 13:53:49 +0100 Subject: [PATCH 166/643] Bump velbusaio to 2023.11.0 (#103798) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 3c773e39e33..1f0dd001853 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2023.10.2"], + "requirements": ["velbus-aio==2023.11.0"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index d5fa9a91e5c..799a7e88059 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2664,7 +2664,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.2 +velbus-aio==2023.11.0 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de51a342b44..63d7557c6ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1982,7 +1982,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.2 +velbus-aio==2023.11.0 # homeassistant.components.venstar venstarcolortouch==0.19 From 92780dd21720da522c024ab691eb4240ea7202aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Nov 2023 02:33:48 -0600 Subject: [PATCH 167/643] Bump pyunifiprotect to 4.21.0 (#103832) changelog: https://github.com/AngellusMortis/pyunifiprotect/compare/v4.20.0...v4.21.0 --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b63700720e6..ee6f6d05548 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.20.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.21.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 799a7e88059..4332a347cb7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2229,7 +2229,7 @@ pytrafikverket==0.3.7 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.20.0 +pyunifiprotect==4.21.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63d7557c6ed..f7f24dc8ccf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1664,7 +1664,7 @@ pytrafikverket==0.3.7 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.20.0 +pyunifiprotect==4.21.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 339e9e7b48c4358397a978768334e537f2caf1e1 Mon Sep 17 00:00:00 2001 From: suaveolent <2163625+suaveolent@users.noreply.github.com> Date: Mon, 13 Nov 2023 10:37:49 +0100 Subject: [PATCH 168/643] Bump lupupy to 0.3.1 (#103835) Co-authored-by: suaveolent --- homeassistant/components/lupusec/binary_sensor.py | 2 +- homeassistant/components/lupusec/manifest.json | 2 +- homeassistant/components/lupusec/switch.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index 2c6e7b2fff8..c98e634dcb3 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -27,7 +27,7 @@ def setup_platform( data = hass.data[LUPUSEC_DOMAIN] - device_types = [CONST.TYPE_OPENING] + device_types = CONST.TYPE_OPENING devices = [] for device in data.lupusec.get_devices(generic_type=device_types): diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index 6fa6c55de2e..e73feef55a1 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/lupusec", "iot_class": "local_polling", "loggers": ["lupupy"], - "requirements": ["lupupy==0.3.0"] + "requirements": ["lupupy==0.3.1"] } diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index 981a2a8633a..37a3b2ec969 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -28,7 +28,7 @@ def setup_platform( data = hass.data[LUPUSEC_DOMAIN] - device_types = [CONST.TYPE_SWITCH] + device_types = CONST.TYPE_SWITCH devices = [] for device in data.lupusec.get_devices(generic_type=device_types): diff --git a/requirements_all.txt b/requirements_all.txt index 4332a347cb7..b1f7d464621 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1185,7 +1185,7 @@ loqedAPI==2.1.8 luftdaten==0.7.4 # homeassistant.components.lupusec -lupupy==0.3.0 +lupupy==0.3.1 # homeassistant.components.lw12wifi lw12==0.9.2 From e89b47138dc0a09f894033cb112f95f5bcce7919 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Nov 2023 09:48:42 -0800 Subject: [PATCH 169/643] Bump gcal_sync to 6.0.1 (#103861) --- homeassistant/components/google/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 509100a5174..fc9107bb8d2 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==5.0.0", "oauth2client==4.1.3"] + "requirements": ["gcal-sync==6.0.1", "oauth2client==4.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b1f7d464621..728d79fe1e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -854,7 +854,7 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==5.0.0 +gcal-sync==6.0.1 # homeassistant.components.geniushub geniushub-client==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7f24dc8ccf..06286df206c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -679,7 +679,7 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==5.0.0 +gcal-sync==6.0.1 # homeassistant.components.geocaching geocachingapi==0.2.1 From c352cf0bd8f58424439233a21cd00cbddba2f1e0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Nov 2023 10:27:02 -0800 Subject: [PATCH 170/643] Fix bug in Fitbit config flow, and switch to prefer display name (#103869) --- homeassistant/components/fitbit/api.py | 2 +- .../components/fitbit/config_flow.py | 2 +- homeassistant/components/fitbit/model.py | 4 +- tests/components/fitbit/conftest.py | 39 +++++++++--- tests/components/fitbit/test_config_flow.py | 63 ++++++++++++++++++- 5 files changed, 96 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index ceb619c4385..49e51a0fd98 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -69,7 +69,7 @@ class FitbitApi(ABC): profile = response["user"] self._profile = FitbitProfile( encoded_id=profile["encodedId"], - full_name=profile["fullName"], + display_name=profile["displayName"], locale=profile.get("locale"), ) return self._profile diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index dd7e79e2c65..7ef6ecbfa28 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -90,7 +90,7 @@ class OAuth2FlowHandler( await self.async_set_unique_id(profile.encoded_id) self._abort_if_unique_id_configured() - return self.async_create_entry(title=profile.full_name, data=data) + return self.async_create_entry(title=profile.display_name, data=data) async def async_step_import(self, data: dict[str, Any]) -> FlowResult: """Handle import from YAML.""" diff --git a/homeassistant/components/fitbit/model.py b/homeassistant/components/fitbit/model.py index 38b1d0bb786..cd8ece163a4 100644 --- a/homeassistant/components/fitbit/model.py +++ b/homeassistant/components/fitbit/model.py @@ -14,8 +14,8 @@ class FitbitProfile: encoded_id: str """The ID representing the Fitbit user.""" - full_name: str - """The first name value specified in the user's account settings.""" + display_name: str + """The name shown when the user's friends look at their Fitbit profile.""" locale: str | None """The locale defined in the user's Fitbit account settings.""" diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 682fb0edd3b..a076be7f63d 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -32,6 +32,15 @@ PROFILE_USER_ID = "fitbit-api-user-id-1" FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" FAKE_AUTH_IMPL = "conftest-imported-cred" +FULL_NAME = "First Last" +DISPLAY_NAME = "First L." +PROFILE_DATA = { + "fullName": FULL_NAME, + "displayName": DISPLAY_NAME, + "displayNameSetting": "name", + "firstName": "First", + "lastName": "Last", +} PROFILE_API_URL = "https://api.fitbit.com/1/user/-/profile.json" DEVICES_API_URL = "https://api.fitbit.com/1/user/-/devices.json" @@ -214,20 +223,34 @@ def mock_profile_locale() -> str: return "en_US" +@pytest.fixture(name="profile_data") +def mock_profile_data() -> dict[str, Any]: + """Fixture to return other profile data fields.""" + return PROFILE_DATA + + +@pytest.fixture(name="profile_response") +def mock_profile_response( + profile_id: str, profile_locale: str, profile_data: dict[str, Any] +) -> dict[str, Any]: + """Fixture to construct the fake profile API response.""" + return { + "user": { + "encodedId": profile_id, + "locale": profile_locale, + **profile_data, + }, + } + + @pytest.fixture(name="profile", autouse=True) -def mock_profile(requests_mock: Mocker, profile_id: str, profile_locale: str) -> None: +def mock_profile(requests_mock: Mocker, profile_response: dict[str, Any]) -> None: """Fixture to setup fake requests made to Fitbit API during config flow.""" requests_mock.register_uri( "GET", PROFILE_API_URL, status_code=HTTPStatus.OK, - json={ - "user": { - "encodedId": profile_id, - "fullName": "My name", - "locale": profile_locale, - }, - }, + json=profile_response, ) diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index d51379c9adc..78d20b0fb58 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -17,8 +17,10 @@ from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir from .conftest import ( CLIENT_ID, + DISPLAY_NAME, FAKE_AUTH_IMPL, PROFILE_API_URL, + PROFILE_DATA, PROFILE_USER_ID, SERVER_ACCESS_TOKEN, ) @@ -76,7 +78,7 @@ async def test_full_flow( entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 config_entry = entries[0] - assert config_entry.title == "My name" + assert config_entry.title == DISPLAY_NAME assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) @@ -286,7 +288,7 @@ async def test_import_fitbit_config( # Verify valid profile can be fetched from the API config_entry = entries[0] - assert config_entry.title == "My name" + assert config_entry.title == DISPLAY_NAME assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) @@ -598,3 +600,60 @@ async def test_reauth_wrong_user_id( assert result.get("reason") == "wrong_account" assert len(mock_setup.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("profile_data", "expected_title"), + [ + (PROFILE_DATA, DISPLAY_NAME), + ({"displayName": DISPLAY_NAME}, DISPLAY_NAME), + ], + ids=("full_profile_data", "display_name_only"), +) +async def test_partial_profile_data( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, + expected_title: str, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + config_entry = entries[0] + assert config_entry.title == expected_title From 29a65d56201c57fae8f620a8938596356e9d6223 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Nov 2023 10:44:26 -0800 Subject: [PATCH 171/643] Fix for Google Calendar API returning invalid RRULE:DATE rules (#103870) --- homeassistant/components/google/calendar.py | 9 +++- tests/components/google/test_calendar.py | 48 +++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index bd0fe18912e..3e34a7234a4 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -521,8 +521,13 @@ class GoogleCalendarEntity( def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" rrule: str | None = None - if len(event.recurrence) == 1: - rrule = event.recurrence[0].lstrip(RRULE_PREFIX) + # Home Assistant expects a single RRULE: and all other rule types are unsupported or ignored + if ( + len(event.recurrence) == 1 + and (raw_rule := event.recurrence[0]) + and raw_rule.startswith(RRULE_PREFIX) + ): + rrule = raw_rule.removeprefix(RRULE_PREFIX) return CalendarEvent( uid=event.ical_uuid, recurrence_id=event.id if event.recurring_event_id else None, diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 3a9673441c0..83544087104 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1301,3 +1301,51 @@ async def test_event_differs_timezone( "description": event["description"], "supported_features": 3, } + + +async def test_invalid_rrule_fix( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_events_list_items, + component_setup, +) -> None: + """Test that an invalid RRULE returned from Google Calendar API is handled correctly end to end.""" + week_from_today = dt_util.now().date() + datetime.timedelta(days=7) + end_event = week_from_today + datetime.timedelta(days=1) + event = { + **TEST_EVENT, + "start": {"date": week_from_today.isoformat()}, + "end": {"date": end_event.isoformat()}, + "recurrence": [ + "RRULE:DATE;TZID=Europe/Warsaw:20230818T020000,20230915T020000,20231013T020000,20231110T010000,20231208T010000", + ], + } + mock_events_list_items([event]) + + assert await component_setup() + + state = hass.states.get(TEST_ENTITY) + assert state.name == TEST_ENTITY_NAME + assert state.state == STATE_OFF + + # Pick a date range that contains two instances of the event + web_client = await hass_client() + response = await web_client.get( + get_events_url(TEST_ENTITY, "2023-08-10T00:00:00Z", "2023-09-20T00:00:00Z") + ) + assert response.status == HTTPStatus.OK + events = await response.json() + + # Both instances are returned, however the RDATE rule is ignored by Home + # Assistant so they are just treateded as flattened events. + assert len(events) == 2 + + event = events[0] + assert event["uid"] == "cydrevtfuybguinhomj@google.com" + assert event["recurrence_id"] == "_c8rinwq863h45qnucyoi43ny8_20230818" + assert event["rrule"] is None + + event = events[1] + assert event["uid"] == "cydrevtfuybguinhomj@google.com" + assert event["recurrence_id"] == "_c8rinwq863h45qnucyoi43ny8_20230915" + assert event["rrule"] is None From cf35e9b1540948baeab6031cb5182bc8fbe922bc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Nov 2023 12:49:49 -0800 Subject: [PATCH 172/643] Update Fitbit to avoid a KeyError when `restingHeartRate` is not present (#103872) * Update Fitbit to avoid a KeyError when `restingHeartRate` is not present * Explicitly handle none response values --- homeassistant/components/fitbit/sensor.py | 13 +++++- tests/components/fitbit/test_sensor.py | 57 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index d0d939ce67e..1bac147306a 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -134,6 +134,17 @@ def _water_unit(unit_system: FitbitUnitSystem) -> UnitOfVolume: return UnitOfVolume.MILLILITERS +def _int_value_or_none(field: str) -> Callable[[dict[str, Any]], int | None]: + """Value function that will parse the specified field if present.""" + + def convert(result: dict[str, Any]) -> int | None: + if (value := result["value"].get(field)) is not None: + return int(value) + return None + + return convert + + @dataclass class FitbitSensorEntityDescription(SensorEntityDescription): """Describes Fitbit sensor entity.""" @@ -206,7 +217,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Resting Heart Rate", native_unit_of_measurement="bpm", icon="mdi:heart-pulse", - value_fn=lambda result: int(result["value"]["restingHeartRate"]), + value_fn=_int_value_or_none("restingHeartRate"), scope=FitbitScope.HEART_RATE, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 5421a652125..d14c7ae78da 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -771,3 +771,60 @@ async def test_device_battery_level_reauth_required( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" + + +@pytest.mark.parametrize( + ("scopes", "response_data", "expected_state"), + [ + (["heartrate"], {}, "unknown"), + ( + ["heartrate"], + { + "restingHeartRate": 120, + }, + "120", + ), + ( + ["heartrate"], + { + "restingHeartRate": 0, + }, + "0", + ), + ], + ids=("missing", "valid", "zero"), +) +async def test_resting_heart_rate_responses( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + response_data: dict[str, Any], + expected_state: str, +) -> None: + """Test resting heart rate sensor with various values from response.""" + + register_timeseries( + "activities/heart", + timeseries_response( + "activities-heart", + { + "customHeartRateZones": [], + "heartRateZones": [ + { + "caloriesOut": 0, + "max": 220, + "min": 159, + "minutes": 0, + "name": "Peak", + }, + ], + **response_data, + }, + ), + ) + assert await integration_setup() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == expected_state From 56298b2c88318e5126891aef913f53e2a42f1c74 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 13 Nov 2023 14:04:12 +0100 Subject: [PATCH 173/643] fix Comelit cover stop (#103911) --- homeassistant/components/comelit/cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 4a3c8eed63c..72bbf56e08a 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -109,7 +109,7 @@ class ComelitCoverEntity( if not self.is_closing and not self.is_opening: return - action = STATE_OFF if self.is_closing else STATE_ON + action = STATE_ON if self.is_closing else STATE_OFF await self._api.set_device_status(COVER, self._device.index, action) @callback From a5a8d38d08c1fbb163cd39f35ea3683548ea73da Mon Sep 17 00:00:00 2001 From: Tom Brien Date: Mon, 13 Nov 2023 19:10:15 +0000 Subject: [PATCH 174/643] Fix Coinbase for new API Structure (#103930) --- .../components/coinbase/config_flow.py | 3 +- homeassistant/components/coinbase/const.py | 4 +- homeassistant/components/coinbase/sensor.py | 42 ++++++++++--------- tests/components/coinbase/common.py | 13 +++++- tests/components/coinbase/const.py | 9 ++-- .../coinbase/snapshots/test_diagnostics.ambr | 24 ++++------- 6 files changed, 50 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 5dc60f535d7..38053295411 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -17,6 +17,7 @@ import homeassistant.helpers.config_validation as cv from . import get_accounts from .const import ( API_ACCOUNT_CURRENCY, + API_ACCOUNT_CURRENCY_CODE, API_RATES, API_RESOURCE_TYPE, API_TYPE_VAULT, @@ -81,7 +82,7 @@ async def validate_options( accounts = await hass.async_add_executor_job(get_accounts, client) accounts_currencies = [ - account[API_ACCOUNT_CURRENCY] + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] for account in accounts if account[API_RESOURCE_TYPE] != API_TYPE_VAULT ] diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index c5fdec4d511..3fc8158f970 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -12,14 +12,16 @@ DOMAIN = "coinbase" API_ACCOUNT_AMOUNT = "amount" API_ACCOUNT_BALANCE = "balance" API_ACCOUNT_CURRENCY = "currency" +API_ACCOUNT_CURRENCY_CODE = "code" API_ACCOUNT_ID = "id" -API_ACCOUNT_NATIVE_BALANCE = "native_balance" +API_ACCOUNT_NATIVE_BALANCE = "balance" API_ACCOUNT_NAME = "name" API_ACCOUNTS_DATA = "data" API_RATES = "rates" API_RESOURCE_PATH = "resource_path" API_RESOURCE_TYPE = "type" API_TYPE_VAULT = "vault" +API_USD = "USD" WALLETS = { "1INCH": "1INCH", diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 47fd3b91129..1442a626f74 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -14,9 +14,9 @@ from .const import ( API_ACCOUNT_AMOUNT, API_ACCOUNT_BALANCE, API_ACCOUNT_CURRENCY, + API_ACCOUNT_CURRENCY_CODE, API_ACCOUNT_ID, API_ACCOUNT_NAME, - API_ACCOUNT_NATIVE_BALANCE, API_RATES, API_RESOURCE_TYPE, API_TYPE_VAULT, @@ -55,7 +55,7 @@ async def async_setup_entry( entities: list[SensorEntity] = [] provided_currencies: list[str] = [ - account[API_ACCOUNT_CURRENCY] + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] for account in instance.accounts if account[API_RESOURCE_TYPE] != API_TYPE_VAULT ] @@ -106,26 +106,28 @@ class AccountSensor(SensorEntity): self._currency = currency for account in coinbase_data.accounts: if ( - account[API_ACCOUNT_CURRENCY] != currency + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] != currency or account[API_RESOURCE_TYPE] == API_TYPE_VAULT ): continue self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}" self._attr_unique_id = ( f"coinbase-{account[API_ACCOUNT_ID]}-wallet-" - f"{account[API_ACCOUNT_CURRENCY]}" + f"{account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]}" ) self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] - self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY] + self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY][ + API_ACCOUNT_CURRENCY_CODE + ] self._attr_icon = CURRENCY_ICONS.get( - account[API_ACCOUNT_CURRENCY], DEFAULT_COIN_ICON + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE], + DEFAULT_COIN_ICON, + ) + self._native_balance = round( + float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]) + / float(coinbase_data.exchange_rates[API_RATES][currency]), + 2, ) - self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_AMOUNT - ] - self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_CURRENCY - ] break self._attr_state_class = SensorStateClass.TOTAL @@ -141,7 +143,7 @@ class AccountSensor(SensorEntity): def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes of the sensor.""" return { - ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._native_currency}", + ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}", } def update(self) -> None: @@ -149,17 +151,17 @@ class AccountSensor(SensorEntity): self._coinbase_data.update() for account in self._coinbase_data.accounts: if ( - account[API_ACCOUNT_CURRENCY] != self._currency + account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] + != self._currency or account[API_RESOURCE_TYPE] == API_TYPE_VAULT ): continue self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] - self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_AMOUNT - ] - self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_CURRENCY - ] + self._native_balance = round( + float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]) + / float(self._coinbase_data.exchange_rates[API_RATES][self._currency]), + 2, + ) break diff --git a/tests/components/coinbase/common.py b/tests/components/coinbase/common.py index 6ab33f3bc7c..0f8930dbeff 100644 --- a/tests/components/coinbase/common.py +++ b/tests/components/coinbase/common.py @@ -6,7 +6,12 @@ from homeassistant.components.coinbase.const import ( ) from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN -from .const import GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2, MOCK_ACCOUNTS_RESPONSE +from .const import ( + GOOD_CURRENCY_2, + GOOD_EXCHANGE_RATE, + GOOD_EXCHANGE_RATE_2, + MOCK_ACCOUNTS_RESPONSE, +) from tests.common import MockConfigEntry @@ -60,7 +65,11 @@ def mock_get_exchange_rates(): """Return a heavily reduced mock list of exchange rates for testing.""" return { "currency": "USD", - "rates": {GOOD_EXCHANGE_RATE_2: "0.109", GOOD_EXCHANGE_RATE: "0.00002"}, + "rates": { + GOOD_CURRENCY_2: "1.0", + GOOD_EXCHANGE_RATE_2: "0.109", + GOOD_EXCHANGE_RATE: "0.00002", + }, } diff --git a/tests/components/coinbase/const.py b/tests/components/coinbase/const.py index 2b437e15478..138b941c62c 100644 --- a/tests/components/coinbase/const.py +++ b/tests/components/coinbase/const.py @@ -12,26 +12,23 @@ BAD_EXCHANGE_RATE = "ETH" MOCK_ACCOUNTS_RESPONSE = [ { "balance": {"amount": "0.00001", "currency": GOOD_CURRENCY}, - "currency": GOOD_CURRENCY, + "currency": {"code": GOOD_CURRENCY}, "id": "123456789", "name": "BTC Wallet", - "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, "type": "wallet", }, { "balance": {"amount": "100.00", "currency": GOOD_CURRENCY}, - "currency": GOOD_CURRENCY, + "currency": {"code": GOOD_CURRENCY}, "id": "abcdefg", "name": "BTC Vault", - "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, "type": "vault", }, { "balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, - "currency": "USD", + "currency": {"code": GOOD_CURRENCY_2}, "id": "987654321", "name": "USD Wallet", - "native_balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, "type": "fiat", }, ] diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr index c214330d5f9..38224a9992f 100644 --- a/tests/components/coinbase/snapshots/test_diagnostics.ambr +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -7,13 +7,11 @@ 'amount': '**REDACTED**', 'currency': 'BTC', }), - 'currency': 'BTC', + 'currency': dict({ + 'code': 'BTC', + }), 'id': '**REDACTED**', 'name': 'BTC Wallet', - 'native_balance': dict({ - 'amount': '**REDACTED**', - 'currency': 'USD', - }), 'type': 'wallet', }), dict({ @@ -21,13 +19,11 @@ 'amount': '**REDACTED**', 'currency': 'BTC', }), - 'currency': 'BTC', + 'currency': dict({ + 'code': 'BTC', + }), 'id': '**REDACTED**', 'name': 'BTC Vault', - 'native_balance': dict({ - 'amount': '**REDACTED**', - 'currency': 'USD', - }), 'type': 'vault', }), dict({ @@ -35,13 +31,11 @@ 'amount': '**REDACTED**', 'currency': 'USD', }), - 'currency': 'USD', + 'currency': dict({ + 'code': 'USD', + }), 'id': '**REDACTED**', 'name': 'USD Wallet', - 'native_balance': dict({ - 'amount': '**REDACTED**', - 'currency': 'USD', - }), 'type': 'fiat', }), ]), From bcd371ac2b8a84abc34e7de7e9c7add97e627ca2 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 14 Nov 2023 02:30:15 -0500 Subject: [PATCH 175/643] Bump zwave-js-server-python to 0.54.0 (#103943) --- homeassistant/components/zwave_js/cover.py | 3 ++- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 364eafd8caf..27919a17614 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -18,6 +18,7 @@ from zwave_js_server.const.command_class.multilevel_switch import ( from zwave_js_server.const.command_class.window_covering import ( NO_POSITION_PROPERTY_KEYS, NO_POSITION_SUFFIX, + WINDOW_COVERING_LEVEL_CHANGE_UP_PROPERTY, SlatStates, ) from zwave_js_server.model.driver import Driver @@ -369,7 +370,7 @@ class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): set_values_func( value, stop_value=self.get_zwave_value( - "levelChangeUp", + WINDOW_COVERING_LEVEL_CHANGE_UP_PROPERTY, value_property_key=value.property_key, ), ) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index f0c1dcec6b5..f2d32d499c9 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.53.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.54.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 728d79fe1e0..1f95654e7cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2824,7 +2824,7 @@ zigpy==0.59.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.53.1 +zwave-js-server-python==0.54.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06286df206c..10e69388532 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2109,7 +2109,7 @@ zigpy-znp==0.11.6 zigpy==0.59.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.53.1 +zwave-js-server-python==0.54.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 19f268a1e1d48035d2b1a19ec2be791f9ffd115a Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Tue, 14 Nov 2023 01:17:44 -0800 Subject: [PATCH 176/643] Update smarttub to 0.0.36 (#103948) --- homeassistant/components/smarttub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index e8db096f31d..f2514063a40 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["smarttub"], "quality_scale": "platinum", - "requirements": ["python-smarttub==0.0.35"] + "requirements": ["python-smarttub==0.0.36"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1f95654e7cd..da17826e522 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2187,7 +2187,7 @@ python-ripple-api==0.0.3 python-roborock==0.35.0 # homeassistant.components.smarttub -python-smarttub==0.0.35 +python-smarttub==0.0.36 # homeassistant.components.songpal python-songpal==0.15.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10e69388532..2ec1f05ad26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1631,7 +1631,7 @@ python-qbittorrent==0.4.3 python-roborock==0.35.0 # homeassistant.components.smarttub -python-smarttub==0.0.35 +python-smarttub==0.0.36 # homeassistant.components.songpal python-songpal==0.15.2 From 2f380d4b750a37b4476981dd6b15bc236bbbd69b Mon Sep 17 00:00:00 2001 From: Chuck Foster <75957355+fosterchuck@users.noreply.github.com> Date: Tue, 14 Nov 2023 08:13:14 -0800 Subject: [PATCH 177/643] Fix duplicate Ban file entries (#103953) --- homeassistant/components/http/ban.py | 5 +++-- tests/components/http/test_ban.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 85feb19a24b..0fa3e95eaf2 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -242,5 +242,6 @@ class IpBanManager: async def async_add_ban(self, remote_addr: IPv4Address | IPv6Address) -> None: """Add a new IP address to the banned list.""" - new_ban = self.ip_bans_lookup[remote_addr] = IpBan(remote_addr) - await self.hass.async_add_executor_job(self._add_ban, new_ban) + if remote_addr not in self.ip_bans_lookup: + new_ban = self.ip_bans_lookup[remote_addr] = IpBan(remote_addr) + await self.hass.async_add_executor_job(self._add_ban, new_ban) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index e6e237a7b67..c5fb56a28fc 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -378,3 +378,29 @@ async def test_failed_login_attempts_counter( resp = await client.get("/auth_true") assert resp.status == HTTPStatus.OK assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2 + + +async def test_single_ban_file_entry( + hass: HomeAssistant, +) -> None: + """Test that only one item is added to ban file.""" + app = web.Application() + app["hass"] = hass + + async def unauth_handler(request): + """Return a mock web response.""" + raise HTTPUnauthorized + + app.router.add_get("/example", unauth_handler) + setup_bans(hass, app, 2) + mock_real_ip(app)("200.201.202.204") + + manager: IpBanManager = app[KEY_BAN_MANAGER] + m_open = mock_open() + + with patch("homeassistant.components.http.ban.open", m_open, create=True): + remote_ip = ip_address("200.201.202.204") + await manager.async_add_ban(remote_ip) + await manager.async_add_ban(remote_ip) + + assert m_open.call_count == 1 From b010c6b7938067cc726026a0a29d2e3cb153e60a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 14 Nov 2023 17:07:27 +0100 Subject: [PATCH 178/643] Fix openexchangerates form data description (#103974) --- homeassistant/components/openexchangerates/config_flow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openexchangerates/config_flow.py b/homeassistant/components/openexchangerates/config_flow.py index a61264dbf41..b78227ed1e5 100644 --- a/homeassistant/components/openexchangerates/config_flow.py +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -66,7 +66,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._reauth_entry.data if self._reauth_entry else {} ) return self.async_show_form( - step_id="user", data_schema=get_data_schema(currencies, existing_data) + step_id="user", + data_schema=get_data_schema(currencies, existing_data), + description_placeholders={ + "signup": "https://openexchangerates.org/signup" + }, ) errors = {} From c241c2f79cd5ed294129b4c9ce880de5f0f8b33e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Nov 2023 03:27:50 -0600 Subject: [PATCH 179/643] Fix emulated_hue with None values (#104020) --- .../components/emulated_hue/hue_api.py | 25 ++++---- tests/components/emulated_hue/test_hue_api.py | 59 +++++++++++++++++++ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 6dfd49c371c..4dbe5aa315e 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -676,19 +676,20 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: @lru_cache(maxsize=512) def _build_entity_state_dict(entity: State) -> dict[str, Any]: """Build a state dict for an entity.""" + is_on = entity.state != STATE_OFF data: dict[str, Any] = { - STATE_ON: entity.state != STATE_OFF, + STATE_ON: is_on, STATE_BRIGHTNESS: None, STATE_HUE: None, STATE_SATURATION: None, STATE_COLOR_TEMP: None, } - if data[STATE_ON]: + attributes = entity.attributes + if is_on: data[STATE_BRIGHTNESS] = hass_to_hue_brightness( - entity.attributes.get(ATTR_BRIGHTNESS, 0) + attributes.get(ATTR_BRIGHTNESS) or 0 ) - hue_sat = entity.attributes.get(ATTR_HS_COLOR) - if hue_sat is not None: + if (hue_sat := attributes.get(ATTR_HS_COLOR)) is not None: hue = hue_sat[0] sat = hue_sat[1] # Convert hass hs values back to hue hs values @@ -697,7 +698,7 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]: else: data[STATE_HUE] = HUE_API_STATE_HUE_MIN data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN - data[STATE_COLOR_TEMP] = entity.attributes.get(ATTR_COLOR_TEMP, 0) + data[STATE_COLOR_TEMP] = attributes.get(ATTR_COLOR_TEMP) or 0 else: data[STATE_BRIGHTNESS] = 0 @@ -706,25 +707,23 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]: data[STATE_COLOR_TEMP] = 0 if entity.domain == climate.DOMAIN: - temperature = entity.attributes.get(ATTR_TEMPERATURE, 0) + temperature = attributes.get(ATTR_TEMPERATURE, 0) # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(temperature * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == humidifier.DOMAIN: - humidity = entity.attributes.get(ATTR_HUMIDITY, 0) + humidity = attributes.get(ATTR_HUMIDITY, 0) # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(humidity * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == media_player.DOMAIN: - level = entity.attributes.get( - ATTR_MEDIA_VOLUME_LEVEL, 1.0 if data[STATE_ON] else 0.0 - ) + level = attributes.get(ATTR_MEDIA_VOLUME_LEVEL, 1.0 if is_on else 0.0) # Convert 0.0-1.0 to 0-254 data[STATE_BRIGHTNESS] = round(min(1.0, level) * HUE_API_STATE_BRI_MAX) elif entity.domain == fan.DOMAIN: - percentage = entity.attributes.get(ATTR_PERCENTAGE) or 0 + percentage = attributes.get(ATTR_PERCENTAGE) or 0 # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(percentage * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == cover.DOMAIN: - level = entity.attributes.get(ATTR_CURRENT_POSITION, 0) + level = attributes.get(ATTR_CURRENT_POSITION, 0) data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX) _clamp_values(data) return data diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index fb5ff265497..98f99349cac 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1694,3 +1694,62 @@ async def test_specificly_exposed_entities( result_json = await async_get_lights(client) assert "1" in result_json + + +async def test_get_light_state_when_none(hass_hue: HomeAssistant, hue_client) -> None: + """Test the getting of light state when brightness is None.""" + hass_hue.states.async_set( + "light.ceiling_lights", + STATE_ON, + { + light.ATTR_BRIGHTNESS: None, + light.ATTR_RGB_COLOR: None, + light.ATTR_HS_COLOR: None, + light.ATTR_COLOR_TEMP: None, + light.ATTR_XY_COLOR: None, + light.ATTR_SUPPORTED_COLOR_MODES: [ + light.COLOR_MODE_COLOR_TEMP, + light.COLOR_MODE_HS, + light.COLOR_MODE_XY, + ], + light.ATTR_COLOR_MODE: light.COLOR_MODE_XY, + }, + ) + + light_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTPStatus.OK + ) + state = light_json["state"] + assert state[HUE_API_STATE_ON] is True + assert state[HUE_API_STATE_BRI] == 1 + assert state[HUE_API_STATE_HUE] == 0 + assert state[HUE_API_STATE_SAT] == 0 + assert state[HUE_API_STATE_CT] == 153 + + hass_hue.states.async_set( + "light.ceiling_lights", + STATE_OFF, + { + light.ATTR_BRIGHTNESS: None, + light.ATTR_RGB_COLOR: None, + light.ATTR_HS_COLOR: None, + light.ATTR_COLOR_TEMP: None, + light.ATTR_XY_COLOR: None, + light.ATTR_SUPPORTED_COLOR_MODES: [ + light.COLOR_MODE_COLOR_TEMP, + light.COLOR_MODE_HS, + light.COLOR_MODE_XY, + ], + light.ATTR_COLOR_MODE: light.COLOR_MODE_XY, + }, + ) + + light_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTPStatus.OK + ) + state = light_json["state"] + assert state[HUE_API_STATE_ON] is False + assert state[HUE_API_STATE_BRI] == 1 + assert state[HUE_API_STATE_HUE] == 0 + assert state[HUE_API_STATE_SAT] == 0 + assert state[HUE_API_STATE_CT] == 153 From 399299c13c0888d51b94b847f78ec3cccb2684f2 Mon Sep 17 00:00:00 2001 From: deosrc Date: Wed, 15 Nov 2023 20:28:16 +0000 Subject: [PATCH 180/643] Fix netatmo authentication when using cloud authentication credentials (#104021) * Fix netatmo authentication loop * Update unit tests * Move logic to determine api scopes * Add unit tests for new method * Use pyatmo scope list (#1) * Exclude scopes not working with cloud * Fix linting error --------- Co-authored-by: Tobias Sauerwein --- homeassistant/components/netatmo/__init__.py | 19 +++++----------- homeassistant/components/netatmo/api.py | 18 +++++++++++++++ .../components/netatmo/config_flow.py | 10 ++------- homeassistant/components/netatmo/const.py | 7 ++++++ tests/components/netatmo/test_api.py | 22 +++++++++++++++++++ 5 files changed, 54 insertions(+), 22 deletions(-) create mode 100644 tests/components/netatmo/test_api.py diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index ddd2fc61ed7..4535805915b 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -8,7 +8,6 @@ from typing import Any import aiohttp import pyatmo -from pyatmo.const import ALL_SCOPES as NETATMO_SCOPES import voluptuous as vol from homeassistant.components import cloud @@ -143,7 +142,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await session.async_ensure_token_valid() except aiohttp.ClientResponseError as ex: - _LOGGER.debug("API error: %s (%s)", ex.status, ex.message) + _LOGGER.warning("API error: %s (%s)", ex.status, ex.message) if ex.status in ( HTTPStatus.BAD_REQUEST, HTTPStatus.UNAUTHORIZED, @@ -152,19 +151,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex raise ConfigEntryNotReady from ex - if entry.data["auth_implementation"] == cloud.DOMAIN: - required_scopes = { - scope - for scope in NETATMO_SCOPES - if scope not in ("access_doorbell", "read_doorbell") - } - else: - required_scopes = set(NETATMO_SCOPES) - - if not (set(session.token["scope"]) & required_scopes): - _LOGGER.debug( + required_scopes = api.get_api_scopes(entry.data["auth_implementation"]) + if not (set(session.token["scope"]) & set(required_scopes)): + _LOGGER.warning( "Session is missing scopes: %s", - required_scopes - set(session.token["scope"]), + set(required_scopes) - set(session.token["scope"]), ) raise ConfigEntryAuthFailed("Token scope not valid, trigger renewal") diff --git a/homeassistant/components/netatmo/api.py b/homeassistant/components/netatmo/api.py index 0b36745338e..7605689b3f5 100644 --- a/homeassistant/components/netatmo/api.py +++ b/homeassistant/components/netatmo/api.py @@ -1,11 +1,29 @@ """API for Netatmo bound to HASS OAuth.""" +from collections.abc import Iterable from typing import cast from aiohttp import ClientSession import pyatmo +from homeassistant.components import cloud from homeassistant.helpers import config_entry_oauth2_flow +from .const import API_SCOPES_EXCLUDED_FROM_CLOUD + + +def get_api_scopes(auth_implementation: str) -> Iterable[str]: + """Return the Netatmo API scopes based on the auth implementation.""" + + if auth_implementation == cloud.DOMAIN: + return set( + { + scope + for scope in pyatmo.const.ALL_SCOPES + if scope not in API_SCOPES_EXCLUDED_FROM_CLOUD + } + ) + return sorted(pyatmo.const.ALL_SCOPES) + class AsyncConfigEntryNetatmoAuth(pyatmo.AbstractAsyncAuth): """Provide Netatmo authentication tied to an OAuth2 based config entry.""" diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index b4e6d838537..bae81a7762f 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -6,7 +6,6 @@ import logging from typing import Any import uuid -from pyatmo.const import ALL_SCOPES import voluptuous as vol from homeassistant import config_entries @@ -15,6 +14,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from .api import get_api_scopes from .const import ( CONF_AREA_NAME, CONF_LAT_NE, @@ -53,13 +53,7 @@ class NetatmoFlowHandler( @property def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" - exclude = [] - if self.flow_impl.name == "Home Assistant Cloud": - exclude = ["access_doorbell", "read_doorbell"] - - scopes = [scope for scope in ALL_SCOPES if scope not in exclude] - scopes.sort() - + scopes = get_api_scopes(self.flow_impl.domain) return {"scope": " ".join(scopes)} async def async_step_user(self, user_input: dict | None = None) -> FlowResult: diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 9e7ac33c8b6..8a281d4d4a2 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -30,6 +30,13 @@ HOME_DATA = "netatmo_home_data" DATA_HANDLER = "netatmo_data_handler" SIGNAL_NAME = "signal_name" +API_SCOPES_EXCLUDED_FROM_CLOUD = [ + "access_doorbell", + "read_doorbell", + "read_mhs1", + "write_mhs1", +] + NETATMO_CREATE_BATTERY = "netatmo_create_battery" NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" diff --git a/tests/components/netatmo/test_api.py b/tests/components/netatmo/test_api.py new file mode 100644 index 00000000000..e2d495555c6 --- /dev/null +++ b/tests/components/netatmo/test_api.py @@ -0,0 +1,22 @@ +"""The tests for the Netatmo api.""" + +from pyatmo.const import ALL_SCOPES + +from homeassistant.components import cloud +from homeassistant.components.netatmo import api +from homeassistant.components.netatmo.const import API_SCOPES_EXCLUDED_FROM_CLOUD + + +async def test_get_api_scopes_cloud() -> None: + """Test method to get API scopes when using cloud auth implementation.""" + result = api.get_api_scopes(cloud.DOMAIN) + + for scope in API_SCOPES_EXCLUDED_FROM_CLOUD: + assert scope not in result + + +async def test_get_api_scopes_other() -> None: + """Test method to get API scopes when using cloud auth implementation.""" + result = api.get_api_scopes("netatmo_239846i2f0j2") + + assert sorted(ALL_SCOPES) == result From 7ff1bdb0981a6ba13f11c26ffe928b13c3786351 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 15 Nov 2023 10:51:12 +0100 Subject: [PATCH 181/643] Fix device tracker see gps accuracy selector (#104022) --- homeassistant/components/device_tracker/services.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml index 08ccbcf0b5a..3199dfd8af1 100644 --- a/homeassistant/components/device_tracker/services.yaml +++ b/homeassistant/components/device_tracker/services.yaml @@ -25,9 +25,9 @@ see: gps_accuracy: selector: number: - min: 1 - max: 100 - unit_of_measurement: "%" + min: 0 + mode: box + unit_of_measurement: "m" battery: selector: number: From 885152df816df7d5e457ad04fd515f2018ec35f6 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Fri, 17 Nov 2023 14:16:53 +0100 Subject: [PATCH 182/643] Bump pyenphase to 1.14.3 (#104101) fix(101354):update pyenphase to 1.14.3 --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 718c33d2811..c8da6f74a40 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.14.2"], + "requirements": ["pyenphase==1.14.3"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index da17826e522..f3758330e88 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1696,7 +1696,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.2 +pyenphase==1.14.3 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ec1f05ad26..f8ec9882920 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1278,7 +1278,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.14.2 +pyenphase==1.14.3 # homeassistant.components.everlights pyeverlights==0.1.0 From d69d9863b5961eb6eefd2c721d907067d79b4daa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Nov 2023 03:08:44 -0600 Subject: [PATCH 183/643] Fix ESPHome BLE client raising confusing error when not connected (#104146) --- .../components/esphome/bluetooth/client.py | 134 ++++++------------ .../esphome/bluetooth/test_client.py | 62 ++++++++ 2 files changed, 107 insertions(+), 89 deletions(-) create mode 100644 tests/components/esphome/bluetooth/test_client.py diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 970e866b27b..6cf1d6b5381 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -75,15 +75,13 @@ def verify_connected(func: _WrapFuncType) -> _WrapFuncType: self: ESPHomeClient, *args: Any, **kwargs: Any ) -> Any: # pylint: disable=protected-access + if not self._is_connected: + raise BleakError(f"{self._description} is not connected") loop = self._loop disconnected_futures = self._disconnected_futures disconnected_future = loop.create_future() disconnected_futures.add(disconnected_future) - ble_device = self._ble_device - disconnect_message = ( - f"{self._source_name }: {ble_device.name} - {ble_device.address}: " - "Disconnected during operation" - ) + disconnect_message = f"{self._description}: Disconnected during operation" try: async with interrupt(disconnected_future, BleakError, disconnect_message): return await func(self, *args, **kwargs) @@ -115,10 +113,8 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: if ex.error.error == -1: # pylint: disable=protected-access _LOGGER.debug( - "%s: %s - %s: BLE device disconnected during %s operation", - self._source_name, - self._ble_device.name, - self._ble_device.address, + "%s: BLE device disconnected during %s operation", + self._description, func.__name__, ) self._async_ble_device_disconnected() @@ -159,10 +155,11 @@ class ESPHomeClient(BaseBleakClient): assert isinstance(address_or_ble_device, BLEDevice) super().__init__(address_or_ble_device, *args, **kwargs) self._loop = asyncio.get_running_loop() - self._ble_device = address_or_ble_device - self._address_as_int = mac_to_int(self._ble_device.address) - assert self._ble_device.details is not None - self._source = self._ble_device.details["source"] + ble_device = address_or_ble_device + self._ble_device = ble_device + self._address_as_int = mac_to_int(ble_device.address) + assert ble_device.details is not None + self._source = ble_device.details["source"] self._cache = client_data.cache self._bluetooth_device = client_data.bluetooth_device self._client = client_data.client @@ -177,8 +174,11 @@ class ESPHomeClient(BaseBleakClient): self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat( client_data.api_version ) - self._address_type = address_or_ble_device.details["address_type"] + self._address_type = ble_device.details["address_type"] self._source_name = f"{client_data.title} [{self._source}]" + self._description = ( + f"{self._source_name}: {ble_device.name} - {ble_device.address}" + ) scanner = client_data.scanner assert scanner is not None self._scanner = scanner @@ -196,12 +196,10 @@ class ESPHomeClient(BaseBleakClient): except (AssertionError, ValueError) as ex: _LOGGER.debug( ( - "%s: %s - %s: Failed to unsubscribe from connection state (likely" + "%s: Failed to unsubscribe from connection state (likely" " connection dropped): %s" ), - self._source_name, - self._ble_device.name, - self._ble_device.address, + self._description, ex, ) self._cancel_connection_state = None @@ -224,22 +222,12 @@ class ESPHomeClient(BaseBleakClient): was_connected = self._is_connected self._async_disconnected_cleanup() if was_connected: - _LOGGER.debug( - "%s: %s - %s: BLE device disconnected", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) + _LOGGER.debug("%s: BLE device disconnected", self._description) self._async_call_bleak_disconnected_callback() def _async_esp_disconnected(self) -> None: """Handle the esp32 client disconnecting from us.""" - _LOGGER.debug( - "%s: %s - %s: ESP device disconnected", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) + _LOGGER.debug("%s: ESP device disconnected", self._description) self._disconnect_callbacks.remove(self._async_esp_disconnected) self._async_ble_device_disconnected() @@ -258,10 +246,8 @@ class ESPHomeClient(BaseBleakClient): ) -> None: """Handle a connect or disconnect.""" _LOGGER.debug( - "%s: %s - %s: Connection state changed to connected=%s mtu=%s error=%s", - self._source_name, - self._ble_device.name, - self._ble_device.address, + "%s: Connection state changed to connected=%s mtu=%s error=%s", + self._description, connected, mtu, error, @@ -300,10 +286,8 @@ class ESPHomeClient(BaseBleakClient): return _LOGGER.debug( - "%s: %s - %s: connected, registering for disconnected callbacks", - self._source_name, - self._ble_device.name, - self._ble_device.address, + "%s: connected, registering for disconnected callbacks", + self._description, ) self._disconnect_callbacks.append(self._async_esp_disconnected) connected_future.set_result(connected) @@ -403,10 +387,8 @@ class ESPHomeClient(BaseBleakClient): if bluetooth_device.ble_connections_free: return _LOGGER.debug( - "%s: %s - %s: Out of connection slots, waiting for a free one", - self._source_name, - self._ble_device.name, - self._ble_device.address, + "%s: Out of connection slots, waiting for a free one", + self._description, ) async with asyncio.timeout(timeout): await bluetooth_device.wait_for_ble_connections_free() @@ -434,7 +416,7 @@ class ESPHomeClient(BaseBleakClient): if response.paired: return True _LOGGER.error( - "Pairing with %s failed due to error: %s", self.address, response.error + "%s: Pairing failed due to error: %s", self._description, response.error ) return False @@ -451,7 +433,7 @@ class ESPHomeClient(BaseBleakClient): if response.success: return True _LOGGER.error( - "Unpairing with %s failed due to error: %s", self.address, response.error + "%s: Unpairing failed due to error: %s", self._description, response.error ) return False @@ -486,30 +468,14 @@ class ESPHomeClient(BaseBleakClient): self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING or dangerous_use_bleak_cache ) and (cached_services := cache.get_gatt_services_cache(address_as_int)): - _LOGGER.debug( - "%s: %s - %s: Cached services hit", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) + _LOGGER.debug("%s: Cached services hit", self._description) self.services = cached_services return self.services - _LOGGER.debug( - "%s: %s - %s: Cached services miss", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) + _LOGGER.debug("%s: Cached services miss", self._description) esphome_services = await self._client.bluetooth_gatt_get_services( address_as_int ) - _LOGGER.debug( - "%s: %s - %s: Got services: %s", - self._source_name, - self._ble_device.name, - self._ble_device.address, - esphome_services, - ) + _LOGGER.debug("%s: Got services: %s", self._description, esphome_services) max_write_without_response = self.mtu_size - GATT_HEADER_SIZE services = BleakGATTServiceCollection() # type: ignore[no-untyped-call] for service in esphome_services.services: @@ -538,12 +504,7 @@ class ESPHomeClient(BaseBleakClient): raise BleakError("Failed to get services from remote esp") self.services = services - _LOGGER.debug( - "%s: %s - %s: Cached services saved", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) + _LOGGER.debug("%s: Cached services saved", self._description) cache.set_gatt_services_cache(address_as_int, services) return services @@ -552,13 +513,15 @@ class ESPHomeClient(BaseBleakClient): ) -> BleakGATTCharacteristic: """Resolve a characteristic specifier to a BleakGATTCharacteristic object.""" if (services := self.services) is None: - raise BleakError("Services have not been resolved") + raise BleakError(f"{self._description}: Services have not been resolved") if not isinstance(char_specifier, BleakGATTCharacteristic): characteristic = services.get_characteristic(char_specifier) else: characteristic = char_specifier if not characteristic: - raise BleakError(f"Characteristic {char_specifier} was not found!") + raise BleakError( + f"{self._description}: Characteristic {char_specifier} was not found!" + ) return characteristic @verify_connected @@ -579,8 +542,8 @@ class ESPHomeClient(BaseBleakClient): if response.success: return True _LOGGER.error( - "Clear cache failed with %s failed due to error: %s", - self.address, + "%s: Clear cache failed due to error: %s", + self._description, response.error, ) return False @@ -692,7 +655,7 @@ class ESPHomeClient(BaseBleakClient): ble_handle = characteristic.handle if ble_handle in self._notify_cancels: raise BleakError( - "Notifications are already enabled on " + f"{self._description}: Notifications are already enabled on " f"service:{characteristic.service_uuid} " f"characteristic:{characteristic.uuid} " f"handle:{ble_handle}" @@ -702,8 +665,8 @@ class ESPHomeClient(BaseBleakClient): and "indicate" not in characteristic.properties ): raise BleakError( - f"Characteristic {characteristic.uuid} does not have notify or indicate" - " property set." + f"{self._description}: Characteristic {characteristic.uuid} " + "does not have notify or indicate property set." ) self._notify_cancels[ @@ -725,18 +688,13 @@ class ESPHomeClient(BaseBleakClient): cccd_descriptor = characteristic.get_descriptor(CCCD_UUID) if not cccd_descriptor: raise BleakError( - f"Characteristic {characteristic.uuid} does not have a " - "characteristic client config descriptor." + f"{self._description}: Characteristic {characteristic.uuid} " + "does not have a characteristic client config descriptor." ) _LOGGER.debug( - ( - "%s: %s - %s: Writing to CCD descriptor %s for notifications with" - " properties=%s" - ), - self._source_name, - self._ble_device.name, - self._ble_device.address, + "%s: Writing to CCD descriptor %s for notifications with properties=%s", + self._description, cccd_descriptor.handle, characteristic.properties, ) @@ -774,12 +732,10 @@ class ESPHomeClient(BaseBleakClient): if self._cancel_connection_state: _LOGGER.warning( ( - "%s: %s - %s: ESPHomeClient bleak client was not properly" + "%s: ESPHomeClient bleak client was not properly" " disconnected before destruction" ), - self._source_name, - self._ble_device.name, - self._ble_device.address, + self._description, ) if not self._loop.is_closed(): self._loop.call_soon_threadsafe(self._async_disconnected_cleanup) diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py new file mode 100644 index 00000000000..7ed1403041d --- /dev/null +++ b/tests/components/esphome/bluetooth/test_client.py @@ -0,0 +1,62 @@ +"""Tests for ESPHomeClient.""" +from __future__ import annotations + +from aioesphomeapi import APIClient, APIVersion, BluetoothProxyFeature, DeviceInfo +from bleak.exc import BleakError +import pytest + +from homeassistant.components.bluetooth import HaBluetoothConnector +from homeassistant.components.esphome.bluetooth.cache import ESPHomeBluetoothCache +from homeassistant.components.esphome.bluetooth.client import ( + ESPHomeClient, + ESPHomeClientData, +) +from homeassistant.components.esphome.bluetooth.device import ESPHomeBluetoothDevice +from homeassistant.components.esphome.bluetooth.scanner import ESPHomeScanner +from homeassistant.core import HomeAssistant + +from tests.components.bluetooth import generate_ble_device + +ESP_MAC_ADDRESS = "AA:BB:CC:DD:EE:FF" +ESP_NAME = "proxy" + + +@pytest.fixture(name="client_data") +async def client_data_fixture( + hass: HomeAssistant, mock_client: APIClient +) -> ESPHomeClientData: + """Return a client data fixture.""" + connector = HaBluetoothConnector(ESPHomeClientData, ESP_MAC_ADDRESS, lambda: True) + return ESPHomeClientData( + bluetooth_device=ESPHomeBluetoothDevice(ESP_NAME, ESP_MAC_ADDRESS), + cache=ESPHomeBluetoothCache(), + client=mock_client, + device_info=DeviceInfo( + mac_address=ESP_MAC_ADDRESS, + name=ESP_NAME, + bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN + & BluetoothProxyFeature.ACTIVE_CONNECTIONS + & BluetoothProxyFeature.REMOTE_CACHING + & BluetoothProxyFeature.PAIRING + & BluetoothProxyFeature.CACHE_CLEARING + & BluetoothProxyFeature.RAW_ADVERTISEMENTS, + ), + api_version=APIVersion(1, 9), + title=ESP_NAME, + scanner=ESPHomeScanner( + hass, ESP_MAC_ADDRESS, ESP_NAME, lambda info: None, connector, True + ), + ) + + +async def test_client_usage_while_not_connected(client_data: ESPHomeClientData) -> None: + """Test client usage while not connected.""" + ble_device = generate_ble_device( + "CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1} + ) + + client = ESPHomeClient(ble_device, client_data=client_data) + with pytest.raises( + BleakError, match=f"{ESP_NAME}.*{ESP_MAC_ADDRESS}.*not connected" + ): + await client.write_gatt_char("test", b"test") is False From fcc70209460b146ae5580d3169f3577593a118df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Nov 2023 07:58:22 +0100 Subject: [PATCH 184/643] Fix memory leak in ESPHome disconnect callbacks (#104149) --- .../components/esphome/bluetooth/client.py | 9 ++++++--- homeassistant/components/esphome/entry_data.py | 18 +++++++++++++++++- homeassistant/components/esphome/manager.py | 16 +++++----------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 6cf1d6b5381..22d4392ce31 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -136,7 +136,7 @@ class ESPHomeClientData: api_version: APIVersion title: str scanner: ESPHomeScanner | None - disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) + disconnect_callbacks: set[Callable[[], None]] = field(default_factory=set) class ESPHomeClient(BaseBleakClient): @@ -215,6 +215,7 @@ class ESPHomeClient(BaseBleakClient): if not future.done(): future.set_result(None) self._disconnected_futures.clear() + self._disconnect_callbacks.discard(self._async_esp_disconnected) self._unsubscribe_connection_state() def _async_ble_device_disconnected(self) -> None: @@ -228,7 +229,9 @@ class ESPHomeClient(BaseBleakClient): def _async_esp_disconnected(self) -> None: """Handle the esp32 client disconnecting from us.""" _LOGGER.debug("%s: ESP device disconnected", self._description) - self._disconnect_callbacks.remove(self._async_esp_disconnected) + # Calling _async_ble_device_disconnected calls + # _async_disconnected_cleanup which will also remove + # the disconnect callbacks self._async_ble_device_disconnected() def _async_call_bleak_disconnected_callback(self) -> None: @@ -289,7 +292,7 @@ class ESPHomeClient(BaseBleakClient): "%s: connected, registering for disconnected callbacks", self._description, ) - self._disconnect_callbacks.append(self._async_esp_disconnected) + self._disconnect_callbacks.add(self._async_esp_disconnected) connected_future.set_result(connected) @api_error_as_bleak_error diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index e53200c2e90..89629a65ea5 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -107,7 +107,7 @@ class RuntimeEntryData: bluetooth_device: ESPHomeBluetoothDevice | None = None api_version: APIVersion = field(default_factory=APIVersion) cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list) - disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) + disconnect_callbacks: set[Callable[[], None]] = field(default_factory=set) state_subscriptions: dict[ tuple[type[EntityState], int], Callable[[], None] ] = field(default_factory=dict) @@ -427,3 +427,19 @@ class RuntimeEntryData: if self.original_options == entry.options: return hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) + + @callback + def async_on_disconnect(self) -> None: + """Call when the entry has been disconnected. + + Safe to call multiple times. + """ + self.available = False + # Make a copy since calling the disconnect callbacks + # may also try to discard/remove themselves. + for disconnect_cb in self.disconnect_callbacks.copy(): + disconnect_cb() + # Make sure to clear the set to give up the reference + # to it and make sure all the callbacks can be GC'd. + self.disconnect_callbacks.clear() + self.disconnect_callbacks = set() diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index d2eca7d39f9..ad226e04061 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -294,7 +294,7 @@ class ESPHomeManager: event.data["entity_id"], attribute, new_state ) - self.entry_data.disconnect_callbacks.append( + self.entry_data.disconnect_callbacks.add( async_track_state_change_event( hass, [entity_id], send_home_assistant_state_event ) @@ -439,7 +439,7 @@ class ESPHomeManager: reconnect_logic.name = device_info.name if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): - entry_data.disconnect_callbacks.append( + entry_data.disconnect_callbacks.add( await async_connect_scanner( hass, entry, cli, entry_data, self.domain_data.bluetooth_cache ) @@ -459,7 +459,7 @@ class ESPHomeManager: await cli.subscribe_home_assistant_states(self.async_on_state_subscription) if device_info.voice_assistant_version: - entry_data.disconnect_callbacks.append( + entry_data.disconnect_callbacks.add( await cli.subscribe_voice_assistant( self._handle_pipeline_start, self._handle_pipeline_stop, @@ -487,10 +487,7 @@ class ESPHomeManager: host, expected_disconnect, ) - for disconnect_cb in entry_data.disconnect_callbacks: - disconnect_cb() - entry_data.disconnect_callbacks = [] - entry_data.available = False + entry_data.async_on_disconnect() entry_data.expected_disconnect = expected_disconnect # Mark state as stale so that we will always dispatch # the next state update of that type when the device reconnects @@ -755,10 +752,7 @@ async def cleanup_instance(hass: HomeAssistant, entry: ConfigEntry) -> RuntimeEn """Cleanup the esphome client if it exists.""" domain_data = DomainData.get(hass) data = domain_data.pop_entry_data(entry) - data.available = False - for disconnect_cb in data.disconnect_callbacks: - disconnect_cb() - data.disconnect_callbacks = [] + data.async_on_disconnect() for cleanup_callback in data.cleanup_callbacks: cleanup_callback() await data.async_cleanup() From 35b1051c677a041849b5de3eede5291e80546fc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Nov 2023 01:35:51 -0500 Subject: [PATCH 185/643] Add debug logging for which adapter is used to connect bluetooth devices (#103264) Log which adapter is used to connect bluetooth devices This is a debug logging improvement to help users find problems with their setup --- homeassistant/components/bluetooth/wrappers.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 97f253f8825..bfcee9d25df 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -270,6 +270,8 @@ class HaBleakClientWrapper(BleakClient): """Connect to the specified GATT server.""" assert models.MANAGER is not None manager = models.MANAGER + if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("%s: Looking for backend to connect", self.__address) wrapped_backend = self._async_get_best_available_backend_and_device(manager) device = wrapped_backend.device scanner = wrapped_backend.scanner @@ -281,12 +283,14 @@ class HaBleakClientWrapper(BleakClient): timeout=self.__timeout, hass=manager.hass, ) - if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): + if debug_logging: # Only lookup the description if we are going to log it description = ble_device_description(device) _, adv = scanner.discovered_devices_and_advertisement_data[device.address] rssi = adv.rssi - _LOGGER.debug("%s: Connecting (last rssi: %s)", description, rssi) + _LOGGER.debug( + "%s: Connecting via %s (last rssi: %s)", description, scanner.name, rssi + ) connected = None try: connected = await super().connect(**kwargs) @@ -301,7 +305,9 @@ class HaBleakClientWrapper(BleakClient): manager.async_release_connection_slot(device) if debug_logging: - _LOGGER.debug("%s: Connected (last rssi: %s)", description, rssi) + _LOGGER.debug( + "%s: Connected via %s (last rssi: %s)", description, scanner.name, rssi + ) return connected @hass_callback From 8b79d38497e7dbd7f1431bed29967c6fdd02148c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 08:22:26 -0600 Subject: [PATCH 186/643] Prevent Bluetooth reconnects from blocking shutdown (#104150) --- homeassistant/components/bluetooth/manager.py | 3 +++ .../components/bluetooth/wrappers.py | 2 ++ tests/components/bluetooth/test_wrappers.py | 23 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 34edccaf4ab..ce047747a0c 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -124,6 +124,7 @@ class BluetoothManager: "storage", "slot_manager", "_debug", + "shutdown", ) def __init__( @@ -165,6 +166,7 @@ class BluetoothManager: self.storage = storage self.slot_manager = slot_manager self._debug = _LOGGER.isEnabledFor(logging.DEBUG) + self.shutdown = False @property def supports_passive_scan(self) -> bool: @@ -259,6 +261,7 @@ class BluetoothManager: def async_stop(self, event: Event) -> None: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") + self.shutdown = True if self._cancel_unavailable_tracking: self._cancel_unavailable_tracking() self._cancel_unavailable_tracking = None diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index bfcee9d25df..9de020f163e 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -270,6 +270,8 @@ class HaBleakClientWrapper(BleakClient): """Connect to the specified GATT server.""" assert models.MANAGER is not None manager = models.MANAGER + if manager.shutdown: + raise BleakError("Bluetooth is already shutdown") if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug("%s: Looking for backend to connect", self.__address) wrapped_backend = self._async_get_best_available_backend_and_device(manager) diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index de646f8ef9c..f69f8971479 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -7,6 +7,7 @@ from unittest.mock import patch import bleak from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from bleak.exc import BleakError import pytest from homeassistant.components.bluetooth import ( @@ -366,3 +367,25 @@ async def test_we_switch_adapters_on_failure( assert await client.connect() is False cancel_hci0() cancel_hci1() + + +async def test_raise_after_shutdown( + hass: HomeAssistant, + two_adapters: None, + enable_bluetooth: None, + install_bleak_catcher, + mock_platform_client_that_raises_on_connect, +) -> None: + """Ensure the slot gets released on connection exception.""" + manager = _get_manager() + hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices( + hass + ) + # hci0 has 2 slots, hci1 has 1 slot + with patch.object(manager, "shutdown", True): + ble_device = hci0_device_advs["00:00:00:00:00:01"][0] + client = bleak.BleakClient(ble_device) + with pytest.raises(BleakError, match="shutdown"): + await client.connect() + cancel_hci0() + cancel_hci1() From 4680ac0cbf26c78e710a185c7e00d11c28cf8fb4 Mon Sep 17 00:00:00 2001 From: Thomas Schamm Date: Sat, 18 Nov 2023 13:39:17 +0100 Subject: [PATCH 187/643] Bump boschshcpy to 0.2.75 (#104159) Bumped to boschshcpy==0.2.75 --- homeassistant/components/bosch_shc/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bosch_shc/manifest.json b/homeassistant/components/bosch_shc/manifest.json index 9fd1055dd60..e29865153b3 100644 --- a/homeassistant/components/bosch_shc/manifest.json +++ b/homeassistant/components/bosch_shc/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/bosch_shc", "iot_class": "local_push", "loggers": ["boschshcpy"], - "requirements": ["boschshcpy==0.2.57"], + "requirements": ["boschshcpy==0.2.75"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index f3758330e88..fa65cf57062 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -568,7 +568,7 @@ bluetooth-data-tools==1.14.0 bond-async==0.2.1 # homeassistant.components.bosch_shc -boschshcpy==0.2.57 +boschshcpy==0.2.75 # homeassistant.components.amazon_polly # homeassistant.components.route53 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8ec9882920..e3693c5eb64 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -482,7 +482,7 @@ bluetooth-data-tools==1.14.0 bond-async==0.2.1 # homeassistant.components.bosch_shc -boschshcpy==0.2.57 +boschshcpy==0.2.75 # homeassistant.components.broadlink broadlink==0.18.3 From 83c59d41546dd7c18dcab28fb8ab5befe372aca2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 19 Nov 2023 11:26:58 -0800 Subject: [PATCH 188/643] Fix Local To-do list bug renaming items (#104182) * Fix Local To-do bug renaming items * Fix renaming --- homeassistant/components/local_todo/todo.py | 4 +- tests/components/local_todo/test_todo.py | 48 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 7e23d01ee46..b688d03253e 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -63,9 +63,11 @@ def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: """Convert TodoItem dataclass items to dictionary of attributes for ical consumption.""" result: dict[str, str] = {} for name, value in obj: + if value is None: + continue if name == "status": result[name] = ICS_TODO_STATUS_MAP_INV[value] - elif value is not None: + else: result[name] = value return result diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 39e9264d45a..5747e05ad05 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -237,6 +237,54 @@ async def test_update_item( assert state.state == "0" +async def test_rename( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test renaming a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + # Rename item + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": item["uid"], "rename": "water"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item has been renamed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "water" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + @pytest.mark.parametrize( ("src_idx", "dst_idx", "expected_items"), [ From 5650df5cfbb8644976773392de177cd950b61f7c Mon Sep 17 00:00:00 2001 From: mkmer Date: Sun, 19 Nov 2023 08:07:24 -0500 Subject: [PATCH 189/643] Bump aiosomecomfort to 0.0.22 (#104202) * Bump aiosomecomfort to 0.0.20 * Bump aiosomecomfort to 0.0.22 --- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index a53eaaab8ce..47213476ad9 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.17"] + "requirements": ["AIOSomecomfort==0.0.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index fa65cf57062..dcef98e3fda 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -8,7 +8,7 @@ AEMET-OpenData==0.4.5 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.17 +AIOSomecomfort==0.0.22 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3693c5eb64..2c2ead33d08 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.4.5 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.17 +AIOSomecomfort==0.0.22 # homeassistant.components.adax Adax-local==0.1.5 From 64297aeb8f7de16a0f0f4ba23072fb2dcf028895 Mon Sep 17 00:00:00 2001 From: Rene Nemec <50780524+ertechdesign@users.noreply.github.com> Date: Sun, 19 Nov 2023 22:49:40 +0000 Subject: [PATCH 190/643] Increase Tomato request timeout (#104203) * tomato integration timeout fixed * update tests in tomato integration --- homeassistant/components/tomato/device_tracker.py | 4 ++-- tests/components/tomato/test_device_tracker.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index da64157dad8..d71dd45bcfe 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -100,10 +100,10 @@ class TomatoDeviceScanner(DeviceScanner): try: if self.ssl: response = requests.Session().send( - self.req, timeout=3, verify=self.verify_ssl + self.req, timeout=60, verify=self.verify_ssl ) else: - response = requests.Session().send(self.req, timeout=3) + response = requests.Session().send(self.req, timeout=60) # Calling and parsing the Tomato api here. We only need the # wldev and dhcpd_lease values. diff --git a/tests/components/tomato/test_device_tracker.py b/tests/components/tomato/test_device_tracker.py index 7c187c7b4bb..11e73b5695c 100644 --- a/tests/components/tomato/test_device_tracker.py +++ b/tests/components/tomato/test_device_tracker.py @@ -157,7 +157,7 @@ def test_config_verify_ssl_but_no_ssl_enabled( assert "_http_id=1234567890" in result.req.body assert "exec=devlist" in result.req.body assert mock_session_send.call_count == 1 - assert mock_session_send.mock_calls[0] == mock.call(result.req, timeout=3) + assert mock_session_send.mock_calls[0] == mock.call(result.req, timeout=60) @mock.patch("os.access", return_value=True) @@ -192,7 +192,7 @@ def test_config_valid_verify_ssl_path(hass: HomeAssistant, mock_session_send) -> assert "exec=devlist" in result.req.body assert mock_session_send.call_count == 1 assert mock_session_send.mock_calls[0] == mock.call( - result.req, timeout=3, verify="/test/tomato.crt" + result.req, timeout=60, verify="/test/tomato.crt" ) @@ -223,7 +223,7 @@ def test_config_valid_verify_ssl_bool(hass: HomeAssistant, mock_session_send) -> assert "exec=devlist" in result.req.body assert mock_session_send.call_count == 1 assert mock_session_send.mock_calls[0] == mock.call( - result.req, timeout=3, verify=False + result.req, timeout=60, verify=False ) From 86beb9d13503008aaa4c2c5c1d667a631e60b11e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Nov 2023 20:15:02 +0100 Subject: [PATCH 191/643] Fix imap does not decode text body correctly (#104217) --- homeassistant/components/imap/coordinator.py | 26 ++++- tests/components/imap/const.py | 99 ++++++++++++++++---- tests/components/imap/test_init.py | 51 +++++++++- 3 files changed, 153 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 59c24b11e51..d77f7fb05bb 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -6,6 +6,7 @@ from collections.abc import Mapping from datetime import datetime, timedelta import email from email.header import decode_header, make_header +from email.message import Message from email.utils import parseaddr, parsedate_to_datetime import logging from typing import Any @@ -96,8 +97,9 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: class ImapMessage: """Class to parse an RFC822 email message.""" - def __init__(self, raw_message: bytes) -> None: + def __init__(self, raw_message: bytes, charset: str = "utf-8") -> None: """Initialize IMAP message.""" + self._charset = charset self.email_message = email.message_from_bytes(raw_message) @property @@ -157,18 +159,30 @@ class ImapMessage: message_html: str | None = None message_untyped_text: str | None = None + def _decode_payload(part: Message) -> str: + """Try to decode text payloads. + + Common text encodings are quoted-printable or base64. + Falls back to the raw content part if decoding fails. + """ + try: + return str(part.get_payload(decode=True).decode(self._charset)) + except Exception: # pylint: disable=broad-except + return str(part.get_payload()) + + part: Message for part in self.email_message.walk(): if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: if message_text is None: - message_text = part.get_payload() + message_text = _decode_payload(part) elif part.get_content_type() == "text/html": if message_html is None: - message_html = part.get_payload() + message_html = _decode_payload(part) elif ( part.get_content_type().startswith("text") and message_untyped_text is None ): - message_untyped_text = part.get_payload() + message_untyped_text = str(part.get_payload()) if message_text is not None: return message_text @@ -223,7 +237,9 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Send a event for the last message if the last message was changed.""" response = await self.imap_client.fetch(last_message_uid, "BODY.PEEK[]") if response.result == "OK": - message = ImapMessage(response.lines[1]) + message = ImapMessage( + response.lines[1], charset=self.config_entry.data[CONF_CHARSET] + ) # Set `initial` to `False` if the last message is triggered again initial: bool = True if (message_id := message.message_id) == self._last_message_id: diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index ec864fd4665..713261936c7 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -18,16 +18,25 @@ TEST_MESSAGE_HEADERS1 = ( b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" ) TEST_MESSAGE_HEADERS2 = ( - b"MIME-Version: 1.0\r\n" b"To: notify@example.com\r\n" b"From: John Doe \r\n" b"Subject: Test subject\r\n" - b"Message-ID: " + b"Message-ID: \r\n" + b"MIME-Version: 1.0\r\n" +) + +TEST_MULTIPART_HEADER = ( + b'Content-Type: multipart/related;\r\n\tboundary="Mark=_100584970350292485166"' ) TEST_MESSAGE_HEADERS3 = b"" TEST_MESSAGE = TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 + +TEST_MESSAGE_MULTIPART = ( + TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 + TEST_MULTIPART_HEADER +) + TEST_MESSAGE_NO_SUBJECT_TO_FROM = ( TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS3 ) @@ -44,21 +53,27 @@ TEST_INVALID_DATE3 = ( TEST_CONTENT_TEXT_BARE = b"\r\nTest body\r\n\r\n" -TEST_CONTENT_BINARY = ( - b"Content-Type: application/binary\r\n" - b"Content-Transfer-Encoding: base64\r\n" - b"\r\n" - b"VGVzdCBib2R5\r\n" -) +TEST_CONTENT_BINARY = b"Content-Type: application/binary\r\n\r\nTest body\r\n" TEST_CONTENT_TEXT_PLAIN = ( - b"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n" - b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" + b'Content-Type: text/plain; charset="utf-8"\r\n' + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n" ) +TEST_CONTENT_TEXT_BASE64 = ( + b'Content-Type: text/plain; charset="utf-8"\r\n' + b"Content-Transfer-Encoding: base64\r\n\r\nVGVzdCBib2R5\r\n" +) + +TEST_CONTENT_TEXT_BASE64_INVALID = ( + b'Content-Type: text/plain; charset="utf-8"\r\n' + b"Content-Transfer-Encoding: base64\r\n\r\nVGVzdCBib2R5invalid\r\n" +) +TEST_BADLY_ENCODED_CONTENT = "VGVzdCBib2R5invalid\r\n" + TEST_CONTENT_TEXT_OTHER = ( b"Content-Type: text/other; charset=UTF-8\r\n" - b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n" ) TEST_CONTENT_HTML = ( @@ -76,14 +91,40 @@ TEST_CONTENT_HTML = ( b"\r\n" b"\r\n" ) +TEST_CONTENT_HTML_BASE64 = ( + b"Content-Type: text/html; charset=UTF-8\r\n" + b"Content-Transfer-Encoding: base64\r\n\r\n" + b"PGh0bWw+CiAgICA8aGVhZD48bWV0YSBodHRwLWVxdW" + b"l2PSJjb250ZW50LXR5cGUiIGNvbnRlbnQ9InRleHQvaHRtbDsgY2hhcnNldD1VVEYtOCI+PC9oZWFkPgog" + b"CAgPGJvZHk+CiAgICAgIDxwPlRlc3QgYm9keTxicj48L3A+CiAgICA8L2JvZHk+CjwvaHRtbD4=\r\n" +) + TEST_CONTENT_MULTIPART = ( b"\r\nThis is a multi-part message in MIME format.\r\n" - + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + TEST_CONTENT_TEXT_PLAIN - + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + TEST_CONTENT_HTML - + b"--------------McwBciN2C0o3rWeF1tmFo2oI--\r\n" + + b"\r\n--Mark=_100584970350292485166--\r\n" +) + +TEST_CONTENT_MULTIPART_BASE64 = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_TEXT_BASE64 + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_HTML_BASE64 + + b"\r\n--Mark=_100584970350292485166--\r\n" +) + +TEST_CONTENT_MULTIPART_BASE64_INVALID = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_TEXT_BASE64_INVALID + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_HTML_BASE64 + + b"\r\n--Mark=_100584970350292485166--\r\n" ) EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) @@ -202,14 +243,40 @@ TEST_FETCH_RESPONSE_MULTIPART = ( "OK", [ b"1 FETCH (BODY[] {" - + str(len(TEST_MESSAGE + TEST_CONTENT_MULTIPART)).encode("utf-8") + + str(len(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART)).encode("utf-8") + b"}", - bytearray(TEST_MESSAGE + TEST_CONTENT_MULTIPART), + bytearray(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) +TEST_FETCH_RESPONSE_MULTIPART_BASE64 = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64)).encode( + "utf-8" + ) + + b"}", + bytearray(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64), b")", b"Fetch completed (0.0001 + 0.000 secs).", ], ) +TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str( + len(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64_INVALID) + ).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_BASE64_INVALID), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM = ( "OK", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index ceda841202c..a00f9d9c25d 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -17,12 +17,15 @@ from homeassistant.util.dt import utcnow from .const import ( BAD_RESPONSE, EMPTY_SEARCH_RESPONSE, + TEST_BADLY_ENCODED_CONTENT, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, TEST_FETCH_RESPONSE_INVALID_DATE1, TEST_FETCH_RESPONSE_INVALID_DATE2, TEST_FETCH_RESPONSE_INVALID_DATE3, TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_MULTIPART_BASE64, + TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID, TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, @@ -110,6 +113,7 @@ async def test_entry_startup_fails( (TEST_FETCH_RESPONSE_TEXT_OTHER, True), (TEST_FETCH_RESPONSE_HTML, True), (TEST_FETCH_RESPONSE_MULTIPART, True), + (TEST_FETCH_RESPONSE_MULTIPART_BASE64, True), (TEST_FETCH_RESPONSE_BINARY, True), ], ids=[ @@ -122,6 +126,7 @@ async def test_entry_startup_fails( "other", "html", "multipart", + "multipart_base64", "binary", ], ) @@ -154,7 +159,7 @@ async def test_receiving_message_successfully( assert data["folder"] == "INBOX" assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" - assert data["text"] + assert "Test body" in data["text"] assert ( valid_date and isinstance(data["date"], datetime) @@ -163,6 +168,48 @@ async def test_receiving_message_successfully( ) +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + ("imap_fetch"), + [ + TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID, + ], + ids=[ + "multipart_base64_invalid", + ], +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_receiving_message_with_invalid_encoding( + hass: HomeAssistant, mock_imap_protocol: MagicMock +) -> None: + """Test receiving a message successfully.""" + event_called = async_capture_events(hass, "imap_content") + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] == TEST_BADLY_ENCODED_CONTENT + + @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize("imap_fetch", [TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM]) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @@ -196,7 +243,7 @@ async def test_receiving_message_no_subject_to_from( assert data["date"] == datetime( 2023, 3, 24, 13, 52, tzinfo=timezone(timedelta(seconds=3600)) ) - assert data["text"] == "Test body\r\n\r\n" + assert data["text"] == "Test body\r\n" assert data["headers"]["Return-Path"] == ("",) assert data["headers"]["Delivered-To"] == ("notify@example.com",) From b64ef24f203408e32d994aaa8146c1b4a455386a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Nov 2023 19:50:25 +0100 Subject: [PATCH 192/643] Fix mqtt json light allows to set brightness value >255 (#104220) --- .../components/mqtt/light/schema_json.py | 11 +++++++---- tests/components/mqtt/test_light_json.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 6f70ff34051..2a2a262be36 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -367,10 +367,13 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): if brightness_supported(self.supported_color_modes): try: if brightness := values["brightness"]: - self._attr_brightness = int( - brightness # type: ignore[operator] - / float(self._config[CONF_BRIGHTNESS_SCALE]) - * 255 + self._attr_brightness = min( + int( + brightness # type: ignore[operator] + / float(self._config[CONF_BRIGHTNESS_SCALE]) + * 255 + ), + 255, ) else: _LOGGER.debug( diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index e7471829856..b3dd3a9a4e3 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -1785,6 +1785,24 @@ async def test_brightness_scale( assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 + # Turn on the light with half brightness + async_fire_mqtt_message( + hass, "test_light_bright_scale", '{"state":"ON", "brightness": 50}' + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 128 + + # Test limmiting max brightness + async_fire_mqtt_message( + hass, "test_light_bright_scale", '{"state":"ON", "brightness": 103}' + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 255 + @pytest.mark.parametrize( "hass_config", From 669daabfdb9811bcddc566c0f0de3004a2cc36c6 Mon Sep 17 00:00:00 2001 From: Anton Tolchanov <1687799+knyar@users.noreply.github.com> Date: Mon, 20 Nov 2023 18:53:25 +0000 Subject: [PATCH 193/643] Handle attributes set to None in prometheus (#104247) Better handle attributes set to None --- .../components/prometheus/__init__.py | 10 ++++++---- tests/components/prometheus/test_init.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index c96ed2e4ed3..561657dcffa 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -19,6 +19,7 @@ from homeassistant.components.climate import ( from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY +from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -323,14 +324,14 @@ class PrometheusMetrics: } def _battery(self, state): - if "battery_level" in state.attributes: + if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is not None: metric = self._metric( "battery_level_percent", self.prometheus_cli.Gauge, "Battery level as a percentage of its capacity", ) try: - value = float(state.attributes[ATTR_BATTERY_LEVEL]) + value = float(battery_level) metric.labels(**self._labels(state)).set(value) except ValueError: pass @@ -434,8 +435,9 @@ class PrometheusMetrics: ) try: - if "brightness" in state.attributes and state.state == STATE_ON: - value = state.attributes["brightness"] / 255.0 + brightness = state.attributes.get(ATTR_BRIGHTNESS) + if state.state == STATE_ON and brightness is not None: + value = brightness / 255.0 else: value = self.state_as_number(state) value = value * 100 diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index f24782b98d4..f28c7b5081b 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -466,6 +466,12 @@ async def test_light(client, light_entities) -> None: 'friendly_name="PC"} 70.58823529411765' in body ) + assert ( + 'light_brightness_percent{domain="light",' + 'entity="light.hallway",' + 'friendly_name="Hallway"} 100.0' in body + ) + @pytest.mark.parametrize("namespace", [""]) async def test_lock(client, lock_entities) -> None: @@ -1492,6 +1498,19 @@ async def light_fixture( data["light_4"] = light_4 data["light_4_attributes"] = light_4_attributes + light_5 = entity_registry.async_get_or_create( + domain=light.DOMAIN, + platform="test", + unique_id="light_5", + suggested_object_id="hallway", + original_name="Hallway", + ) + # Light is on, but brightness is unset; expect metrics to report + # brightness of 100%. + light_5_attributes = {light.ATTR_BRIGHTNESS: None} + set_state_with_entry(hass, light_5, STATE_ON, light_5_attributes) + data["light_5"] = light_5 + data["light_5_attributes"] = light_5_attributes await hass.async_block_till_done() return data From a5d48da07a9fa0ad207c5a27f8d57c1203a401cc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Nov 2023 12:58:55 +0100 Subject: [PATCH 194/643] Catch ClientOSError in renault integration (#104248) --- homeassistant/components/renault/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index f69451290bc..6b5679088a0 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(DOMAIN, {}) try: await renault_hub.async_initialise(config_entry) - except aiohttp.ClientResponseError as exc: + except aiohttp.ClientError as exc: raise ConfigEntryNotReady() from exc hass.data[DOMAIN][config_entry.entry_id] = renault_hub From ae2ff926c1dbfed05b0b61421ab6434d68e7571e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Nov 2023 07:59:39 +0100 Subject: [PATCH 195/643] Restore removed guard for non-string inputs in Alexa (#104263) --- homeassistant/components/alexa/capabilities.py | 6 ++++-- tests/components/alexa/test_capabilities.py | 3 ++- tests/components/alexa/test_smart_home.py | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index cde90e127f3..0856c39946b 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -857,16 +857,18 @@ class AlexaInputController(AlexaCapability): def inputs(self) -> list[dict[str, str]] | None: """Return the list of valid supported inputs.""" - source_list: list[str] = self.entity.attributes.get( + source_list: list[Any] = self.entity.attributes.get( media_player.ATTR_INPUT_SOURCE_LIST, [] ) return AlexaInputController.get_valid_inputs(source_list) @staticmethod - def get_valid_inputs(source_list: list[str]) -> list[dict[str, str]]: + def get_valid_inputs(source_list: list[Any]) -> list[dict[str, str]]: """Return list of supported inputs.""" input_list: list[dict[str, str]] = [] for source in source_list: + if not isinstance(source, str): + continue formatted_source = ( source.lower().replace("-", "").replace("_", "").replace(" ", "") ) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index a6be57e9ed5..11e39c40cb1 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -183,7 +183,7 @@ async def test_api_increase_color_temp( ("domain", "payload", "source_list", "idx"), [ ("media_player", "GAME CONSOLE", ["tv", "game console", 10000], 1), - ("media_player", "SATELLITE TV", ["satellite-tv", "game console"], 0), + ("media_player", "SATELLITE TV", ["satellite-tv", "game console", None], 0), ("media_player", "SATELLITE TV", ["satellite_tv", "game console"], 0), ("media_player", "BAD DEVICE", ["satellite_tv", "game console"], None), ], @@ -864,6 +864,7 @@ async def test_report_playback_state(hass: HomeAssistant) -> None: | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP, "volume_level": 0.75, + "source_list": [None], }, ) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index e24ec4c950b..7a1abe96110 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1439,6 +1439,8 @@ async def test_media_player_inputs(hass: HomeAssistant) -> None: "aux", "input 1", "tv", + 0, + None, ], }, ) From da04c32893e477eb6bf57d0bf32f60704a54f3c4 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Tue, 21 Nov 2023 07:50:00 +0100 Subject: [PATCH 196/643] Bump bimmer_connected to 0.14.3 (#104282) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index b5652694120..911a998371e 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.14.2"] + "requirements": ["bimmer-connected==0.14.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dcef98e3fda..9e8ff2d1b76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -524,7 +524,7 @@ beautifulsoup4==4.12.2 bellows==0.36.8 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.2 +bimmer-connected==0.14.3 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2c2ead33d08..0894d396b38 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -448,7 +448,7 @@ beautifulsoup4==4.12.2 bellows==0.36.8 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.2 +bimmer-connected==0.14.3 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 From 40326385ae91c721be4349dbd65233309aa0f5b9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 20 Nov 2023 22:57:31 -0800 Subject: [PATCH 197/643] Bump pyrainbird to 4.0.1 (#104293) --- homeassistant/components/rainbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 07a0bc0a5f6..b8cb86264f2 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rainbird", "iot_class": "local_polling", "loggers": ["pyrainbird"], - "requirements": ["pyrainbird==4.0.0"] + "requirements": ["pyrainbird==4.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9e8ff2d1b76..e48f37c55ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1980,7 +1980,7 @@ pyqwikswitch==0.93 pyrail==0.0.3 # homeassistant.components.rainbird -pyrainbird==4.0.0 +pyrainbird==4.0.1 # homeassistant.components.recswitch pyrecswitch==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0894d396b38..ce32f0665a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1496,7 +1496,7 @@ pyps4-2ndscreen==1.3.1 pyqwikswitch==0.93 # homeassistant.components.rainbird -pyrainbird==4.0.0 +pyrainbird==4.0.1 # homeassistant.components.risco pyrisco==0.5.7 From da992e9f45824184635687c359880caf463bb536 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Nov 2023 17:41:04 +0100 Subject: [PATCH 198/643] Bump pychromecast to 13.0.8 (#104320) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 7cf318f12a6..5035b3c6620 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==13.0.7"], + "requirements": ["PyChromecast==13.0.8"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e48f37c55ed..ff8328e8e08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -55,7 +55,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==13.0.7 +PyChromecast==13.0.8 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce32f0665a0..c6bd7472213 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -48,7 +48,7 @@ PlexAPI==4.15.4 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==13.0.7 +PyChromecast==13.0.8 # homeassistant.components.flick_electric PyFlick==0.0.2 From 1200ded24c326c2ccfca203f19b293eeca4a208d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Nov 2023 09:12:24 +0100 Subject: [PATCH 199/643] Bumped version to 2023.11.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 479bfcbac6e..c03087dc10f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 55dd7a81a37..eb2ca031685 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.2" +version = "2023.11.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f5783cd3b5e8739c41815b82619161e945702aad Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 5 Nov 2023 23:54:30 -0800 Subject: [PATCH 200/643] Bump ical to 6.0.0 (#103482) --- homeassistant/components/local_calendar/calendar.py | 6 +++--- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/local_todo/todo.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index c8807d40cc1..2a90e3e9e19 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -9,9 +9,9 @@ from typing import Any from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream from ical.event import Event +from ical.exceptions import CalendarParseError from ical.store import EventStore, EventStoreError from ical.types import Range, Recur -from pydantic import ValidationError import voluptuous as vol from homeassistant.components.calendar import ( @@ -178,8 +178,8 @@ def _parse_event(event: dict[str, Any]) -> Event: event[key] = dt_util.as_local(value).replace(tzinfo=None) try: - return Event.parse_obj(event) - except ValidationError as err: + return Event(**event) + except CalendarParseError as err: _LOGGER.debug("Error parsing event input fields: %s (%s)", event, str(err)) raise vol.Invalid("Error parsing event input fields") from err diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index ac95c6b0f0e..d21048c191c 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==5.1.0"] + "requirements": ["ical==6.0.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 049a1824495..cf2a49f6510 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==5.1.0"] + "requirements": ["ical==6.0.0"] } diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index b688d03253e..cd30c2eeebe 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -7,9 +7,9 @@ from typing import Any from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream +from ical.exceptions import CalendarParseError from ical.store import TodoStore from ical.todo import Todo, TodoStatus -from pydantic import ValidationError from homeassistant.components.todo import ( TodoItem, @@ -76,7 +76,7 @@ def _convert_item(item: TodoItem) -> Todo: """Convert a HomeAssistant TodoItem to an ical Todo.""" try: return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory)) - except ValidationError as err: + except CalendarParseError as err: _LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err) raise HomeAssistantError("Error parsing todo input fields") from err diff --git a/requirements_all.txt b/requirements_all.txt index ff8328e8e08..1dce7aa8bbc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1047,7 +1047,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==5.1.0 +ical==6.0.0 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6bd7472213..4ac8f14118b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -827,7 +827,7 @@ ibeacon-ble==1.0.1 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==5.1.0 +ical==6.0.0 # homeassistant.components.ping icmplib==3.0 From 9c4fd88a3d6b01eb6e09244fc7a5874ebcbbc3c9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 11 Nov 2023 03:57:05 -0800 Subject: [PATCH 201/643] Bump ical to 6.1.0 (#103759) --- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/xmpp/manifest.json | 2 +- requirements_all.txt | 5 ++++- requirements_test_all.txt | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index d21048c191c..d7b16ee3bef 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==6.0.0"] + "requirements": ["ical==6.1.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index cf2a49f6510..4c3a8e10a62 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==6.0.0"] + "requirements": ["ical==6.1.0"] } diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json index 99b3ff126d3..30dee6c842b 100644 --- a/homeassistant/components/xmpp/manifest.json +++ b/homeassistant/components/xmpp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/xmpp", "iot_class": "cloud_push", "loggers": ["pyasn1", "slixmpp"], - "requirements": ["slixmpp==1.8.4"] + "requirements": ["slixmpp==1.8.4", "emoji==2.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1dce7aa8bbc..cae2ddb153b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -742,6 +742,9 @@ elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 +# homeassistant.components.xmpp +emoji==2.8.0 + # homeassistant.components.emulated_roku emulated-roku==0.2.1 @@ -1047,7 +1050,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.0.0 +ical==6.1.0 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ac8f14118b..c910fa8e4bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -827,7 +827,7 @@ ibeacon-ble==1.0.1 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.0.0 +ical==6.1.0 # homeassistant.components.ping icmplib==3.0 From a9a95ad8819b0c93c564b5c34daccf7fee9e758c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 29 Nov 2023 08:42:02 +0100 Subject: [PATCH 202/643] Revert "Introduce base entity for ping" (#104682) --- .../components/ping/binary_sensor.py | 12 ++++-- .../components/ping/device_tracker.py | 42 ++++++++++++------- homeassistant/components/ping/entity.py | 28 ------------- .../ping/snapshots/test_binary_sensor.ambr | 34 ++------------- tests/components/ping/test_binary_sensor.py | 11 +---- tests/components/ping/test_device_tracker.py | 23 ---------- 6 files changed, 39 insertions(+), 111 deletions(-) delete mode 100644 homeassistant/components/ping/entity.py diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index e6cad32f3de..97636111586 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -18,11 +18,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN from .coordinator import PingUpdateCoordinator -from .entity import BasePingEntity _LOGGER = logging.getLogger(__name__) @@ -84,16 +84,20 @@ async def async_setup_entry( async_add_entities([PingBinarySensor(entry, data.coordinators[entry.entry_id])]) -class PingBinarySensor(BasePingEntity, BinarySensorEntity): +class PingBinarySensor(CoordinatorEntity[PingUpdateCoordinator], BinarySensorEntity): """Representation of a Ping Binary sensor.""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_available = False def __init__( self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator ) -> None: """Initialize the Ping Binary sensor.""" - super().__init__(config_entry, coordinator) + super().__init__(coordinator) + + self._attr_name = config_entry.title + self._attr_unique_id = config_entry.entry_id # if this was imported just enable it when it was enabled before if CONF_IMPORTED_BY in config_entry.data: @@ -108,7 +112,7 @@ class PingBinarySensor(BasePingEntity, BinarySensorEntity): @property def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the ICMP echo request.""" + """Return the state attributes of the ICMP checo request.""" if self.coordinator.data.data is None: return None return { diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 1bce965ee55..ceff1b2e124 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -8,26 +8,21 @@ import voluptuous as vol from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, AsyncSeeCallback, + ScannerEntity, SourceType, ) -from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_HOSTS, - CONF_NAME, - STATE_HOME, - STATE_NOT_HOME, -) +from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DOMAIN -from .entity import BasePingEntity +from .coordinator import PingUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -89,20 +84,37 @@ async def async_setup_entry( async_add_entities([PingDeviceTracker(entry, data.coordinators[entry.entry_id])]) -class PingDeviceTracker(BasePingEntity, BaseTrackerEntity): +class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): """Representation of a Ping device tracker.""" + def __init__( + self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator + ) -> None: + """Initialize the Ping device tracker.""" + super().__init__(coordinator) + + self._attr_name = config_entry.title + self.config_entry = config_entry + + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self.coordinator.data.ip_address + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self.config_entry.entry_id + @property def source_type(self) -> SourceType: """Return the source type which is router.""" return SourceType.ROUTER @property - def state(self) -> str: - """Return the state of the device.""" - if self.coordinator.data.is_alive: - return STATE_HOME - return STATE_NOT_HOME + def is_connected(self) -> bool: + """Return true if ping returns is_alive.""" + return self.coordinator.data.is_alive @property def entity_registry_enabled_default(self) -> bool: diff --git a/homeassistant/components/ping/entity.py b/homeassistant/components/ping/entity.py deleted file mode 100644 index 058d8c967e5..00000000000 --- a/homeassistant/components/ping/entity.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Base entity for Ping integration.""" -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import PingUpdateCoordinator - - -class BasePingEntity(CoordinatorEntity[PingUpdateCoordinator]): - """Representation of a Ping base entity.""" - - _attr_has_entity_name = True - _attr_name = None - - def __init__( - self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator - ) -> None: - """Initialize the Ping Binary sensor.""" - super().__init__(coordinator) - - self._attr_unique_id = config_entry.entry_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.coordinator.data.ip_address)}, - manufacturer="Ping", - ) - - self.config_entry = config_entry diff --git a/tests/components/ping/snapshots/test_binary_sensor.ambr b/tests/components/ping/snapshots/test_binary_sensor.ambr index f570a8afc51..2ce320d561b 100644 --- a/tests/components/ping/snapshots/test_binary_sensor.ambr +++ b/tests/components/ping/snapshots/test_binary_sensor.ambr @@ -72,7 +72,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.10_10_10_10', - 'has_entity_name': True, + 'has_entity_name': False, 'hidden_by': None, 'icon': None, 'id': , @@ -81,7 +81,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': None, + 'original_name': '10.10.10.10', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -90,34 +90,6 @@ }) # --- # name: test_setup_and_update.1 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'ping', - '10.10.10.10', - ), - }), - 'is_new': False, - 'manufacturer': 'Ping', - 'model': None, - 'name': '10.10.10.10', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_setup_and_update.2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -134,7 +106,7 @@ 'state': 'on', }) # --- -# name: test_setup_and_update.3 +# name: test_setup_and_update.2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index 5eab92b1139..b1066895e2b 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -10,11 +10,7 @@ from syrupy.filters import props from homeassistant.components.ping.const import CONF_IMPORTED_BY, DOMAIN from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -24,7 +20,6 @@ from tests.common import MockConfigEntry async def test_setup_and_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: @@ -34,10 +29,6 @@ async def test_setup_and_update( entry = entity_registry.async_get("binary_sensor.10_10_10_10") assert entry == snapshot(exclude=props("unique_id")) - # check the device - device = device_registry.async_get_device({(DOMAIN, "10.10.10.10")}) - assert device == snapshot - state = hass.states.get("binary_sensor.10_10_10_10") assert state == snapshot diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index a180e8d745e..b6cc6b42912 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,9 +1,5 @@ """Test the binary sensor platform of ping.""" -from datetime import timedelta -from unittest.mock import patch -from freezegun.api import FrozenDateTimeFactory -from icmplib import Host import pytest from homeassistant.components.ping.const import DOMAIN @@ -19,7 +15,6 @@ async def test_setup_and_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test sensor setup and update.""" @@ -47,24 +42,6 @@ async def test_setup_and_update( state = hass.states.get("device_tracker.10_10_10_10") assert state.state == "home" - freezer.tick(timedelta(minutes=5)) - await hass.async_block_till_done() - - # check device tracker is still "home" - state = hass.states.get("device_tracker.10_10_10_10") - assert state.state == "home" - - # check if device tracker updates to "not home" - with patch( - "homeassistant.components.ping.helpers.async_ping", - return_value=Host(address="10.10.10.10", packets_sent=10, rtts=[]), - ): - freezer.tick(timedelta(minutes=5)) - await hass.async_block_till_done() - - state = hass.states.get("device_tracker.10_10_10_10") - assert state.state == "not_home" - async def test_import_issue_creation( hass: HomeAssistant, From 68722ce66265559e1a4d4856c88f8b2f26665965 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 29 Nov 2023 08:43:48 +0100 Subject: [PATCH 203/643] Bump aioairq to 0.3.1 (#104659) --- homeassistant/components/airq/const.py | 1 - homeassistant/components/airq/coordinator.py | 6 ++---- homeassistant/components/airq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airq/test_config_flow.py | 2 +- 6 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py index 82719515cbf..d1a2340b4bc 100644 --- a/homeassistant/components/airq/const.py +++ b/homeassistant/components/airq/const.py @@ -3,7 +3,6 @@ from typing import Final DOMAIN: Final = "airq" MANUFACTURER: Final = "CorantGmbH" -TARGET_ROUTE: Final = "average" CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" UPDATE_INTERVAL: float = 10.0 diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 2d0d9d199df..76459005c45 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL +from .const import DOMAIN, MANUFACTURER, UPDATE_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -56,6 +56,4 @@ class AirQCoordinator(DataUpdateCoordinator): hw_version=info["hw_version"], ) ) - - data = await self.airq.get(TARGET_ROUTE) - return self.airq.drop_uncertainties_from_data(data) + return await self.airq.get_latest_data() diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 97fb70c1b05..156f167913b 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.2.4"] + "requirements": ["aioairq==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 64349902be8..6f1cbffe6bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.2.4 +aioairq==0.3.1 # homeassistant.components.airzone_cloud aioairzone-cloud==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6be6f60474e..a4651fe495d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.2.4 +aioairq==0.3.1 # homeassistant.components.airzone_cloud aioairzone-cloud==0.3.6 diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 252c12f80fa..52fc8d2300b 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,7 +1,7 @@ """Test the air-Q config flow.""" from unittest.mock import patch -from aioairq.core import DeviceInfo, InvalidAuth, InvalidInput +from aioairq import DeviceInfo, InvalidAuth, InvalidInput from aiohttp.client_exceptions import ClientConnectionError import pytest From 4b667cff26476ba6a8be03f64d3eacc4954b6682 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 29 Nov 2023 08:44:28 +0100 Subject: [PATCH 204/643] Host field description: implement review from #104658 (#104685) --- homeassistant/components/airtouch4/strings.json | 2 +- homeassistant/components/airvisual_pro/strings.json | 2 +- homeassistant/components/alarmdecoder/strings.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json index c810b991b8e..04c2e54cc7e 100644 --- a/homeassistant/components/airtouch4/strings.json +++ b/homeassistant/components/airtouch4/strings.json @@ -14,7 +14,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of the device running your AirTouch controller." + "host": "The hostname or IP address of your AirTouch controller." } } } diff --git a/homeassistant/components/airvisual_pro/strings.json b/homeassistant/components/airvisual_pro/strings.json index 6f690c9eb4a..641fa8963da 100644 --- a/homeassistant/components/airvisual_pro/strings.json +++ b/homeassistant/components/airvisual_pro/strings.json @@ -14,7 +14,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "ip_address": "The hostname or IP address of the device running your AirVisual Pro." + "ip_address": "The hostname or IP address of your AirVisual Pro device." } } }, diff --git a/homeassistant/components/alarmdecoder/strings.json b/homeassistant/components/alarmdecoder/strings.json index e1b5a774a87..dd698201b09 100644 --- a/homeassistant/components/alarmdecoder/strings.json +++ b/homeassistant/components/alarmdecoder/strings.json @@ -16,7 +16,7 @@ "device_path": "Device Path" }, "data_description": { - "host": "The hostname or IP address of the machine connected to the AlarmDecoder device.", + "host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.", "port": "The port on which AlarmDecoder is accessible (for example, 10000)" } } From 3aa9066a507f7407ce232165a5ee34a017277cfc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 29 Nov 2023 08:45:47 +0100 Subject: [PATCH 205/643] Add field description for Shelly host (#104686) --- homeassistant/components/shelly/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index b12ad3e4823..49c66a56459 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -6,6 +6,9 @@ "description": "Before setup, battery-powered devices must be woken up, you can now wake the device up using a button on it.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Shelly device to control." } }, "credentials": { From 526180a8afcbf76940d5e3db81cb9f34f031cf42 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Wed, 29 Nov 2023 03:08:27 -0500 Subject: [PATCH 206/643] Add PECO smart meter binary_sensor (#71034) * Add support for PECO smart meter * Add support for PECO smart meter * Conform to black * Fix tests and additional clean-up * Return init file to original state * Move to FlowResultType * Catch up to upstream * Remove commented code * isort * Merge smart meter and outage count into one entry * Test coverage * Remove logging exceptions from config flow verification * Fix comments from @emontnemery * Revert "Add support for PECO smart meter" This reverts commit 36ca90856684f328e71bc3778fa7aa52a6bde5ca. * More fixes --- homeassistant/components/peco/__init__.py | 58 +++++- .../components/peco/binary_sensor.py | 59 ++++++ homeassistant/components/peco/config_flow.py | 91 ++++++++- homeassistant/components/peco/const.py | 4 +- homeassistant/components/peco/sensor.py | 2 +- homeassistant/components/peco/strings.json | 18 +- tests/components/peco/test_config_flow.py | 176 +++++++++++++++--- tests/components/peco/test_init.py | 160 +++++++++++++++- 8 files changed, 525 insertions(+), 43 deletions(-) create mode 100644 homeassistant/components/peco/binary_sensor.py diff --git a/homeassistant/components/peco/__init__.py b/homeassistant/components/peco/__init__.py index ad74200dace..bcdc4195100 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -5,7 +5,14 @@ from dataclasses import dataclass from datetime import timedelta from typing import Final -from peco import AlertResults, BadJSONError, HttpError, OutageResults, PecoOutageApi +from peco import ( + AlertResults, + BadJSONError, + HttpError, + OutageResults, + PecoOutageApi, + UnresponsiveMeterError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -13,9 +20,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_COUNTY, DOMAIN, LOGGER, SCAN_INTERVAL +from .const import ( + CONF_COUNTY, + CONF_PHONE_NUMBER, + DOMAIN, + LOGGER, + OUTAGE_SCAN_INTERVAL, + SMART_METER_SCAN_INTERVAL, +) -PLATFORMS: Final = [Platform.SENSOR] +PLATFORMS: Final = [Platform.SENSOR, Platform.BINARY_SENSOR] @dataclass @@ -31,9 +45,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession = async_get_clientsession(hass) api = PecoOutageApi() + # Outage Counter Setup county: str = entry.data[CONF_COUNTY] - async def async_update_data() -> PECOCoordinatorData: + async def async_update_outage_data() -> OutageResults: """Fetch data from API.""" try: outages: OutageResults = ( @@ -53,15 +68,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, LOGGER, name="PECO Outage Count", - update_method=async_update_data, - update_interval=timedelta(minutes=SCAN_INTERVAL), + update_method=async_update_outage_data, + update_interval=timedelta(minutes=OUTAGE_SCAN_INTERVAL), ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"outage_count": coordinator} + if phone_number := entry.data.get(CONF_PHONE_NUMBER): + # Smart Meter Setup] + + async def async_update_meter_data() -> bool: + """Fetch data from API.""" + try: + data: bool = await api.meter_check(phone_number, websession) + except UnresponsiveMeterError as err: + raise UpdateFailed("Unresponsive meter") from err + except HttpError as err: + raise UpdateFailed(f"Error fetching data: {err}") from err + except BadJSONError as err: + raise UpdateFailed(f"Error parsing data: {err}") from err + return data + + coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name="PECO Smart Meter", + update_method=async_update_meter_data, + update_interval=timedelta(minutes=SMART_METER_SCAN_INTERVAL), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id]["smart_meter"] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/peco/binary_sensor.py b/homeassistant/components/peco/binary_sensor.py new file mode 100644 index 00000000000..7f0402b207f --- /dev/null +++ b/homeassistant/components/peco/binary_sensor.py @@ -0,0 +1,59 @@ +"""Binary sensor for PECO outage counter.""" +from __future__ import annotations + +from typing import Final + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + +PARALLEL_UPDATES: Final = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor for PECO.""" + if "smart_meter" not in hass.data[DOMAIN][config_entry.entry_id]: + return + coordinator: DataUpdateCoordinator[bool] = hass.data[DOMAIN][config_entry.entry_id][ + "smart_meter" + ] + + async_add_entities( + [PecoBinarySensor(coordinator, phone_number=config_entry.data["phone_number"])] + ) + + +class PecoBinarySensor( + CoordinatorEntity[DataUpdateCoordinator[bool]], BinarySensorEntity +): + """Binary sensor for PECO outage counter.""" + + _attr_icon = "mdi:gauge" + _attr_device_class = BinarySensorDeviceClass.POWER + _attr_name = "Meter Status" + + def __init__( + self, coordinator: DataUpdateCoordinator[bool], phone_number: str + ) -> None: + """Initialize binary sensor for PECO.""" + super().__init__(coordinator) + self._attr_unique_id = f"{phone_number}" + + @property + def is_on(self) -> bool: + """Return if the meter has power.""" + return self.coordinator.data diff --git a/homeassistant/components/peco/config_flow.py b/homeassistant/components/peco/config_flow.py index 63ca7f3291a..261cdb031bf 100644 --- a/homeassistant/components/peco/config_flow.py +++ b/homeassistant/components/peco/config_flow.py @@ -1,41 +1,122 @@ """Config flow for PECO Outage Counter integration.""" from __future__ import annotations +import logging from typing import Any +from peco import ( + HttpError, + IncompatibleMeterError, + PecoOutageApi, + UnresponsiveMeterError, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv -from .const import CONF_COUNTY, COUNTY_LIST, DOMAIN +from .const import CONF_COUNTY, CONF_PHONE_NUMBER, COUNTY_LIST, DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_COUNTY): vol.In(COUNTY_LIST), + vol.Optional(CONF_PHONE_NUMBER): cv.string, } ) +_LOGGER = logging.getLogger(__name__) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for PECO Outage Counter.""" VERSION = 1 + meter_verification: bool = False + meter_data: dict[str, str] = {} + meter_error: dict[str, str] = {} + + async def _verify_meter(self, phone_number: str) -> None: + """Verify if the meter is compatible.""" + + api = PecoOutageApi() + + try: + await api.meter_check(phone_number) + except ValueError: + self.meter_error = {"phone_number": "invalid_phone_number", "type": "error"} + except IncompatibleMeterError: + self.meter_error = {"phone_number": "incompatible_meter", "type": "abort"} + except UnresponsiveMeterError: + self.meter_error = {"phone_number": "unresponsive_meter", "type": "error"} + except HttpError: + self.meter_error = {"phone_number": "http_error", "type": "error"} + + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" + if self.meter_verification is True: + return self.async_show_progress_done(next_step_id="finish_smart_meter") + if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, ) county = user_input[CONF_COUNTY] - await self.async_set_unique_id(county) + if CONF_PHONE_NUMBER not in user_input: + await self.async_set_unique_id(county) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"{user_input[CONF_COUNTY].capitalize()} Outage Count", + data=user_input, + ) + + phone_number = user_input[CONF_PHONE_NUMBER] + + await self.async_set_unique_id(f"{county}-{phone_number}") self._abort_if_unique_id_configured() - return self.async_create_entry( - title=f"{county.capitalize()} Outage Count", data=user_input + self.meter_verification = True + + if self.meter_error is not None: + # Clear any previous errors, since the user may have corrected them + self.meter_error = {} + + self.hass.async_create_task(self._verify_meter(phone_number)) + + self.meter_data = user_input + + return self.async_show_progress( + step_id="user", + progress_action="verifying_meter", + ) + + async def async_step_finish_smart_meter( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the finish smart meter step.""" + if "phone_number" in self.meter_error: + if self.meter_error["type"] == "error": + self.meter_verification = False + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"phone_number": self.meter_error["phone_number"]}, + ) + + return self.async_abort(reason=self.meter_error["phone_number"]) + + return self.async_create_entry( + title=f"{self.meter_data[CONF_COUNTY].capitalize()} - {self.meter_data[CONF_PHONE_NUMBER]}", + data=self.meter_data, ) diff --git a/homeassistant/components/peco/const.py b/homeassistant/components/peco/const.py index b0198ac8761..1df8ae41ecb 100644 --- a/homeassistant/components/peco/const.py +++ b/homeassistant/components/peco/const.py @@ -14,6 +14,8 @@ COUNTY_LIST: Final = [ "TOTAL", ] CONFIG_FLOW_COUNTIES: Final = [{county: county.capitalize()} for county in COUNTY_LIST] -SCAN_INTERVAL: Final = 9 +OUTAGE_SCAN_INTERVAL: Final = 9 # minutes +SMART_METER_SCAN_INTERVAL: Final = 15 # minutes CONF_COUNTY: Final = "county" ATTR_CONTENT: Final = "content" +CONF_PHONE_NUMBER: Final = "phone_number" diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index 5be41f7c7e1..935f2b659f9 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -91,7 +91,7 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" county: str = config_entry.data[CONF_COUNTY] - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id]["outage_count"] async_add_entities( PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST diff --git a/homeassistant/components/peco/strings.json b/homeassistant/components/peco/strings.json index 059b2ba71a7..cdf5bb497db 100644 --- a/homeassistant/components/peco/strings.json +++ b/homeassistant/components/peco/strings.json @@ -3,12 +3,26 @@ "step": { "user": { "data": { - "county": "County" + "county": "County", + "phone_number": "Phone Number" + }, + "data_description": { + "county": "County used for outage number retrieval", + "phone_number": "Phone number associated with the PECO account (optional). Adding a phone number adds a binary sensor confirming if your power is out or not, and not an issue with a breaker or an issue on your end." } } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "incompatible_meter": "Your meter is not compatible with smart meter checking." + }, + "progress": { + "verifying_meter": "One moment. Verifying that your meter is compatible. This may take a minute or two." + }, + "error": { + "invalid_phone_number": "Please enter a valid phone number.", + "unresponsive_meter": "Your meter is not responding. Please try again later.", + "http_error": "There was an error communicating with PECO. The issue that is most likely is that you entered an invalid phone number. Please check the phone number or try again later." } }, "entity": { diff --git a/tests/components/peco/test_config_flow.py b/tests/components/peco/test_config_flow.py index 532450f0099..ca6759baeff 100644 --- a/tests/components/peco/test_config_flow.py +++ b/tests/components/peco/test_config_flow.py @@ -1,6 +1,7 @@ """Test the PECO Outage Counter config flow.""" from unittest.mock import patch +from peco import HttpError, IncompatibleMeterError, UnresponsiveMeterError import pytest from voluptuous.error import MultipleInvalid @@ -17,6 +18,7 @@ async def test_form(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.FORM assert result["errors"] is None + assert result["step_id"] == "user" with patch( "homeassistant.components.peco.async_setup_entry", @@ -35,6 +37,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "county": "PHILADELPHIA", } + assert result2["context"]["unique_id"] == "PHILADELPHIA" async def test_invalid_county(hass: HomeAssistant) -> None: @@ -43,37 +46,160 @@ async def test_invalid_county(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - with pytest.raises(MultipleInvalid): - await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "county": "INVALID_COUNTY_THAT_SHOULD_NOT_EXIST", - }, - ) - await hass.async_block_till_done() - - second_result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert second_result["type"] == FlowResultType.FORM - assert second_result["errors"] is None + assert result["step_id"] == "user" with patch( "homeassistant.components.peco.async_setup_entry", return_value=True, - ): - second_result2 = await hass.config_entries.flow.async_configure( - second_result["flow_id"], + ), pytest.raises(MultipleInvalid): + await hass.config_entries.flow.async_configure( + result["flow_id"], { - "county": "PHILADELPHIA", + "county": "INVALID_COUNTY_THAT_SHOULDNT_EXIST", }, ) await hass.async_block_till_done() - assert second_result2["type"] == FlowResultType.CREATE_ENTRY - assert second_result2["title"] == "Philadelphia Outage Count" - assert second_result2["data"] == { - "county": "PHILADELPHIA", - } + +async def test_meter_value_error(hass: HomeAssistant) -> None: + """Test if the MeterValueError error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "INVALID_SMART_METER_THAT_SHOULD_NOT_EXIST", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "invalid_phone_number"} + + +async def test_incompatible_meter_error(hass: HomeAssistant) -> None: + """Test if the IncompatibleMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=IncompatibleMeterError()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "incompatible_meter" + + +async def test_unresponsive_meter_error(hass: HomeAssistant) -> None: + """Test if the UnresponsiveMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=UnresponsiveMeterError()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "unresponsive_meter"} + + +async def test_meter_http_error(hass: HomeAssistant) -> None: + """Test if the InvalidMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=HttpError): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "http_error"} + + +async def test_smart_meter(hass: HomeAssistant) -> None: + """Test if the Smart Meter step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "verifying_meter" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Philadelphia - 1234567890" + assert result["data"]["phone_number"] == "1234567890" + assert result["context"]["unique_id"] == "PHILADELPHIA-1234567890" diff --git a/tests/components/peco/test_init.py b/tests/components/peco/test_init.py index 52a7ddd3b25..2919e508c97 100644 --- a/tests/components/peco/test_init.py +++ b/tests/components/peco/test_init.py @@ -2,7 +2,13 @@ import asyncio from unittest.mock import patch -from peco import AlertResults, BadJSONError, HttpError, OutageResults +from peco import ( + AlertResults, + BadJSONError, + HttpError, + OutageResults, + UnresponsiveMeterError, +) import pytest from homeassistant.components.peco.const import DOMAIN @@ -14,6 +20,7 @@ from tests.common import MockConfigEntry MOCK_ENTRY_DATA = {"county": "TOTAL"} COUNTY_ENTRY_DATA = {"county": "BUCKS"} INVALID_COUNTY_DATA = {"county": "INVALID"} +METER_DATA = {"county": "BUCKS", "phone_number": "1234567890"} async def test_unload_entry(hass: HomeAssistant) -> None: @@ -149,3 +156,154 @@ async def test_bad_json(hass: HomeAssistant, sensor: str) -> None: assert hass.states.get(f"sensor.{sensor}") is None assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_unresponsive_meter_error(hass: HomeAssistant) -> None: + """Test if it raises an error when the meter will not respond.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=UnresponsiveMeterError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_http_error(hass: HomeAssistant) -> None: + """Test if it raises an error when there is an HTTP error.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=HttpError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_bad_json(hass: HomeAssistant) -> None: + """Test if it raises an error when there is bad JSON.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=BadJSONError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_timeout(hass: HomeAssistant) -> None: + """Test if it raises an error when there is a timeout.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=asyncio.TimeoutError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_data(hass: HomeAssistant) -> None: + """Test if the meter returns the value successfully.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + return_value=True, + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is not None + assert hass.states.get("binary_sensor.meter_status").state == "on" + assert config_entry.state == ConfigEntryState.LOADED From 8e8e8077a099afc208544111216fa7fe04daea0d Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:13:35 +0100 Subject: [PATCH 207/643] Agent DVR and Android IP webcam: Add description of host field (#104688) --- homeassistant/components/agent_dvr/strings.json | 3 +++ homeassistant/components/android_ip_webcam/strings.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/agent_dvr/strings.json b/homeassistant/components/agent_dvr/strings.json index 77167b8294b..cbfc2e87a4d 100644 --- a/homeassistant/components/agent_dvr/strings.json +++ b/homeassistant/components/agent_dvr/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The IP address of the Agent DVR server." } } }, diff --git a/homeassistant/components/android_ip_webcam/strings.json b/homeassistant/components/android_ip_webcam/strings.json index db21a690984..57e5452b900 100644 --- a/homeassistant/components/android_ip_webcam/strings.json +++ b/homeassistant/components/android_ip_webcam/strings.json @@ -7,6 +7,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The IP address of the device running the Android IP Webcam app. The IP address is shown in the app once you start the server." } } }, From 5dc64dd6b9532028c1b6c1fa49c90fd852c11cd6 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:16:58 +0100 Subject: [PATCH 208/643] Fix HA state update in ViCare number platform (#104687) use sync update fn --- homeassistant/components/vicare/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 45abd0a5cda..f8abea13401 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -144,7 +144,7 @@ class ViCareNumber(ViCareEntity, NumberEntity): """Set new value.""" if self.entity_description.value_setter: self.entity_description.value_setter(self._api, value) - self.async_write_ha_state() + self.schedule_update_ha_state() def update(self) -> None: """Update state of number.""" From 2663a4d61758e68379eac673fc6216857848b055 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Nov 2023 09:19:02 +0100 Subject: [PATCH 209/643] Bump zha-quirks to 0.0.107 (#104683) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/zha_devices_list.py | 10 ++++++++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index af2c8405e5f..786caf1809c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -24,7 +24,7 @@ "bellows==0.36.8", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.106", + "zha-quirks==0.0.107", "zigpy-deconz==0.21.1", "zigpy==0.59.0", "zigpy-xbee==0.19.0", diff --git a/requirements_all.txt b/requirements_all.txt index 6f1cbffe6bc..cb7964e7c2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2819,7 +2819,7 @@ zeroconf==0.127.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.106 +zha-quirks==0.0.107 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4651fe495d..f646b41e3cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2114,7 +2114,7 @@ zeroconf==0.127.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.106 +zha-quirks==0.0.107 # homeassistant.components.zha zigpy-deconz==0.21.1 diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index c193cd509f3..65ef55c4711 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -2178,6 +2178,16 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_power_factor", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_instantaneous_demand", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", From c4e3ae84f438b6c7d1b4b754e1f026e0b1a38356 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:28:56 +0100 Subject: [PATCH 210/643] Optimize async executor use in ViCare integration (#104645) * use one async executor * use list comprehension * simplify * simplify * simplify * simplify * simplify * simplify * simplify * simplify * add type * Apply suggestions from code review * fix ruff findings --- .../components/vicare/binary_sensor.py | 119 ++++++++++-------- homeassistant/components/vicare/button.py | 39 +++--- homeassistant/components/vicare/climate.py | 35 ++++-- homeassistant/components/vicare/number.py | 44 +++---- homeassistant/components/vicare/sensor.py | 96 ++++++++++---- .../components/vicare/water_heater.py | 31 +++-- 6 files changed, 218 insertions(+), 146 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index f92f24d01ce..525099e7d4e 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -7,6 +7,9 @@ import logging from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceWithComponent, +) from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -104,39 +107,67 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ) -def _build_entity( - vicare_api: PyViCareDevice, +def _build_entities( + device: PyViCareDevice, device_config: PyViCareDeviceConfig, - entity_description: ViCareBinarySensorEntityDescription, -): - """Create a ViCare binary sensor entity.""" - if is_supported(entity_description.key, entity_description, vicare_api): - return ViCareBinarySensor( - vicare_api, - device_config, - entity_description, +) -> list[ViCareBinarySensor]: + """Create ViCare binary sensor entities for a device.""" + + entities: list[ViCareBinarySensor] = _build_entities_for_device( + device, device_config + ) + entities.extend( + _build_entities_for_component( + get_circuits(device), device_config, CIRCUIT_SENSORS ) - return None + ) + entities.extend( + _build_entities_for_component( + get_burners(device), device_config, BURNER_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_compressors(device), device_config, COMPRESSOR_SENSORS + ) + ) + return entities -async def _entities_from_descriptions( - hass: HomeAssistant, - entities: list[ViCareBinarySensor], - sensor_descriptions: tuple[ViCareBinarySensorEntityDescription, ...], - iterables, - config_entry: ConfigEntry, -) -> None: - """Create entities from descriptions and list of burners/circuits.""" - for description in sensor_descriptions: - for current in iterables: - entity = await hass.async_add_executor_job( - _build_entity, - current, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, - ) - if entity: - entities.append(entity) +def _build_entities_for_device( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareBinarySensor]: + """Create device specific ViCare binary sensor entities.""" + + return [ + ViCareBinarySensor( + device, + device_config, + description, + ) + for description in GLOBAL_SENSORS + if is_supported(description.key, description, device) + ] + + +def _build_entities_for_component( + components: list[PyViCareHeatingDeviceWithComponent], + device_config: PyViCareDeviceConfig, + entity_descriptions: tuple[ViCareBinarySensorEntityDescription, ...], +) -> list[ViCareBinarySensor]: + """Create component specific ViCare binary sensor entities.""" + + return [ + ViCareBinarySensor( + component, + device_config, + description, + ) + for component in components + for description in entity_descriptions + if is_supported(description.key, description, component) + ] async def async_setup_entry( @@ -146,36 +177,16 @@ async def async_setup_entry( ) -> None: """Create the ViCare binary sensor devices.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - entities = [] - - for description in GLOBAL_SENSORS: - entity = await hass.async_add_executor_job( - _build_entity, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, + device_config, ) - if entity: - entities.append(entity) - - circuits = await hass.async_add_executor_job(get_circuits, api) - await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, circuits, config_entry ) - burners = await hass.async_add_executor_job(get_burners, api) - await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, burners, config_entry - ) - - compressors = await hass.async_add_executor_job(get_compressors, api) - await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, compressors, config_entry - ) - - async_add_entities(entities) - class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): """Representation of a ViCare sensor.""" diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index d0e50b5f772..374d98b3397 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -47,19 +47,21 @@ BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = ( ) -def _build_entity( - vicare_api: PyViCareDevice, +def _build_entities( + api: PyViCareDevice, device_config: PyViCareDeviceConfig, - entity_description: ViCareButtonEntityDescription, -): - """Create a ViCare button entity.""" - if is_supported(entity_description.key, entity_description, vicare_api): - return ViCareButton( - vicare_api, +) -> list[ViCareButton]: + """Create ViCare button entities for a device.""" + + return [ + ViCareButton( + api, device_config, - entity_description, + description, ) - return None + for description in BUTTON_DESCRIPTIONS + if is_supported(description.key, description, api) + ] async def async_setup_entry( @@ -69,20 +71,15 @@ async def async_setup_entry( ) -> None: """Create the ViCare button entities.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - entities = [] - - for description in BUTTON_DESCRIPTIONS: - entity = await hass.async_add_executor_job( - _build_entity, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, + device_config, ) - if entity: - entities.append(entity) - - async_add_entities(entities) + ) class ViCareButton(ViCareEntity, ButtonEntity): diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index c1e04e1d1b2..c14f940ffe6 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -95,25 +95,30 @@ HA_TO_VICARE_PRESET_HEATING = { } +def _build_entities( + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareClimate]: + """Create ViCare climate entities for a device.""" + return [ + ViCareClimate( + api, + circuit, + device_config, + "heating", + ) + for circuit in get_circuits(api) + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = await hass.async_add_executor_job(get_circuits, api) - - for circuit in circuits: - entity = ViCareClimate( - api, - circuit, - device_config, - "heating", - ) - entities.append(entity) platform = entity_platform.async_get_current_platform() @@ -123,7 +128,13 @@ async def async_setup_entry( "set_vicare_mode", ) - async_add_entities(entities) + async_add_entities( + await hass.async_add_executor_job( + _build_entities, + api, + device_config, + ) + ) class ViCareClimate(ViCareEntity, ClimateEntity): diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index f8abea13401..5511f2a5294 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -80,19 +80,22 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( ) -def _build_entity( - vicare_api: PyViCareHeatingDeviceComponent, +def _build_entities( + api: PyViCareDevice, device_config: PyViCareDeviceConfig, - entity_description: ViCareNumberEntityDescription, -) -> ViCareNumber | None: - """Create a ViCare number entity.""" - if is_supported(entity_description.key, entity_description, vicare_api): - return ViCareNumber( - vicare_api, +) -> list[ViCareNumber]: + """Create ViCare number entities for a component.""" + + return [ + ViCareNumber( + circuit, device_config, - entity_description, + description, ) - return None + for circuit in get_circuits(api) + for description in CIRCUIT_ENTITY_DESCRIPTIONS + if is_supported(description.key, description, circuit) + ] async def async_setup_entry( @@ -101,23 +104,16 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare number devices.""" - entities: list[ViCareNumber] = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = await hass.async_add_executor_job(get_circuits, api) - for circuit in circuits: - for description in CIRCUIT_ENTITY_DESCRIPTIONS: - entity = await hass.async_add_executor_job( - _build_entity, - circuit, - device_config, - description, - ) - if entity: - entities.append(entity) - - async_add_entities(entities) + async_add_entities( + await hass.async_add_executor_job( + _build_entities, + api, + device_config, + ) + ) class ViCareNumber(ViCareEntity, NumberEntity): diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index bfad8b107cb..875d8790c52 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -6,8 +6,11 @@ from contextlib import suppress from dataclasses import dataclass import logging -from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceWithComponent, +) from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -59,7 +62,7 @@ VICARE_UNIT_TO_DEVICE_CLASS = { class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare sensor entity.""" - unit_getter: Callable[[Device], str | None] | None = None + unit_getter: Callable[[PyViCareDevice], str | None] | None = None GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( @@ -628,45 +631,86 @@ async def _entities_from_descriptions( entities.append(entity) +def _build_entities( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareSensor]: + """Create ViCare sensor entities for a device.""" + + entities: list[ViCareSensor] = _build_entities_for_device(device, device_config) + entities.extend( + _build_entities_for_component( + get_circuits(device), device_config, CIRCUIT_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_burners(device), device_config, BURNER_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_compressors(device), device_config, COMPRESSOR_SENSORS + ) + ) + return entities + + +def _build_entities_for_device( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareSensor]: + """Create device specific ViCare sensor entities.""" + + return [ + ViCareSensor( + device, + device_config, + description, + ) + for description in GLOBAL_SENSORS + if is_supported(description.key, description, device) + ] + + +def _build_entities_for_component( + components: list[PyViCareHeatingDeviceWithComponent], + device_config: PyViCareDeviceConfig, + entity_descriptions: tuple[ViCareSensorEntityDescription, ...], +) -> list[ViCareSensor]: + """Create component specific ViCare sensor entities.""" + + return [ + ViCareSensor( + component, + device_config, + description, + ) + for component in components + for description in entity_descriptions + if is_supported(description.key, description, component) + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" - api: Device = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + api: PyViCareDevice = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config: PyViCareDeviceConfig = hass.data[DOMAIN][config_entry.entry_id][ VICARE_DEVICE_CONFIG ] - entities = [] - for description in GLOBAL_SENSORS: - entity = await hass.async_add_executor_job( - _build_entity, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, device_config, - description, ) - if entity: - entities.append(entity) - - circuits = await hass.async_add_executor_job(get_circuits, api) - await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, circuits, config_entry ) - burners = await hass.async_add_executor_job(get_burners, api) - await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, burners, config_entry - ) - - compressors = await hass.async_add_executor_job(get_compressors, api) - await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, compressors, config_entry - ) - - async_add_entities(entities) - class ViCareSensor(ViCareEntity, SensorEntity): """Representation of a ViCare sensor.""" diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 9b154da2bc2..036ced5ee55 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -1,4 +1,6 @@ """Viessmann ViCare water_heater device.""" +from __future__ import annotations + from contextlib import suppress import logging from typing import Any @@ -58,27 +60,38 @@ HA_TO_VICARE_HVAC_DHW = { } +def _build_entities( + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareWater]: + """Create ViCare water entities for a device.""" + return [ + ViCareWater( + api, + circuit, + device_config, + "water", + ) + for circuit in get_circuits(api) + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = await hass.async_add_executor_job(get_circuits, api) - for circuit in circuits: - entity = ViCareWater( + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - circuit, device_config, - "water", ) - entities.append(entity) - - async_add_entities(entities) + ) class ViCareWater(ViCareEntity, WaterHeaterEntity): From 4d00767081717d62f64a7187474aa81d719f2c18 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:35:08 +0100 Subject: [PATCH 211/643] ASUSWRT: add description of host field. Fix title (#104690) Co-authored-by: Franck Nijhof --- homeassistant/components/asuswrt/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index cf105a6a708..8a3207ec7cb 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -2,7 +2,6 @@ "config": { "step": { "user": { - "title": "AsusWRT", "description": "Set required parameter to connect to your router", "data": { "host": "[%key:common::config_flow::data::host%]", @@ -11,10 +10,12 @@ "ssh_key": "Path to your SSH key file (instead of password)", "protocol": "Communication protocol to use", "port": "Port (leave empty for protocol default)" + }, + "data_description": { + "host": "The hostname or IP address of your ASUSWRT router." } }, "legacy": { - "title": "AsusWRT", "description": "Set required parameters to connect to your router", "data": { "mode": "Router operating mode" @@ -37,7 +38,6 @@ "options": { "step": { "init": { - "title": "AsusWRT Options", "data": { "consider_home": "Seconds to wait before considering a device away", "track_unknown": "Track unknown / unnamed devices", From 8c56b5ef826abe7bcc78d3dd6cf9d937a751c649 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 29 Nov 2023 09:35:38 +0100 Subject: [PATCH 212/643] Add a host field description for Bravia, Brother and NAM (#104689) --- homeassistant/components/braviatv/strings.json | 3 +++ homeassistant/components/brother/strings.json | 3 +++ homeassistant/components/nam/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json index 8f8e728cb9d..4b28fa91d74 100644 --- a/homeassistant/components/braviatv/strings.json +++ b/homeassistant/components/braviatv/strings.json @@ -5,6 +5,9 @@ "description": "Ensure that your TV is turned on before trying to set it up.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Sony Bravia TV to control." } }, "authorize": { diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index e24c941c514..0d8f4f4eedf 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "type": "Type of the printer" + }, + "data_description": { + "host": "The hostname or IP address of the Brother printer to control." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index e443a398984..83a40d87f76 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -6,6 +6,9 @@ "description": "Set up Nettigo Air Monitor integration.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Nettigo Air Monitor to control." } }, "credentials": { From 6a878767292f2825c45a175022cd8f594dd98caa Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 29 Nov 2023 09:39:30 +0100 Subject: [PATCH 213/643] Handle server disconnection for Vodafone devices (#104650) --- homeassistant/components/vodafone_station/coordinator.py | 7 ++++--- homeassistant/components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index a2cddcf9a65..ff51f009f3c 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -97,6 +97,9 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): try: try: await self.api.login() + raw_data_devices = await self.api.get_devices_data() + data_sensors = await self.api.get_sensor_data() + await self.api.logout() except exceptions.CannotAuthenticate as err: raise ConfigEntryAuthFailed from err except ( @@ -117,10 +120,8 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): dev_info, utc_point_in_time ), ) - for dev_info in (await self.api.get_devices_data()).values() + for dev_info in (raw_data_devices).values() } - data_sensors = await self.api.get_sensor_data() - await self.api.logout() return UpdateCoordinatorDataType(data_devices, data_sensors) @property diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 2a1814c83d0..20ea4db057e 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.4.2"] + "requirements": ["aiovodafone==0.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index cb7964e7c2f..bce0550bb44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -380,7 +380,7 @@ aiounifi==66 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.2 +aiovodafone==0.4.3 # homeassistant.components.waqi aiowaqi==3.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f646b41e3cc..a58be624388 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aiounifi==66 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.2 +aiovodafone==0.4.3 # homeassistant.components.waqi aiowaqi==3.0.1 From a3bad5458320ee7b5d70bf758ed4e6b471790319 Mon Sep 17 00:00:00 2001 From: Sebastian YEPES Date: Wed, 29 Nov 2023 09:48:45 +0100 Subject: [PATCH 214/643] Add Tuya Smart Water Timer (#95053) Co-authored-by: Franck Nijhof --- homeassistant/components/tuya/const.py | 2 ++ homeassistant/components/tuya/select.py | 10 ++++++++++ homeassistant/components/tuya/sensor.py | 12 ++++++++++++ homeassistant/components/tuya/strings.json | 16 ++++++++++++++++ homeassistant/components/tuya/switch.py | 8 ++++++++ 5 files changed, 48 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index acf9f8bbd2c..19faa76a191 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -338,6 +338,7 @@ class DPCode(StrEnum): TEMP_VALUE_V2 = "temp_value_v2" TEMPER_ALARM = "temper_alarm" # Tamper alarm TIME_TOTAL = "time_total" + TIME_USE = "time_use" # Total seconds of irrigation TOTAL_CLEAN_AREA = "total_clean_area" TOTAL_CLEAN_COUNT = "total_clean_count" TOTAL_CLEAN_TIME = "total_clean_time" @@ -362,6 +363,7 @@ class DPCode(StrEnum): WATER_RESET = "water_reset" # Resetting of water usage days WATER_SET = "water_set" # Water level WATERSENSOR_STATE = "watersensor_state" + WEATHER_DELAY = "weather_delay" WET = "wet" # Humidification WINDOW_CHECK = "window_check" WINDOW_STATE = "window_state" diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 3cc8c72f555..bc44ddf479c 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -75,6 +75,16 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { icon="mdi:thermometer-lines", ), ), + # Smart Water Timer + "sfkzq": ( + # Irrigation will not be run within this set delay period + SelectEntityDescription( + key=DPCode.WEATHER_DELAY, + translation_key="weather_delay", + icon="mdi:weather-cloudy-clock", + entity_category=EntityCategory.CONFIG, + ), + ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 313900fab4e..4bf8808f5f1 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -517,6 +517,18 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Smart Water Timer + "sfkzq": ( + # Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug) + TuyaSensorEntityDescription( + key=DPCode.TIME_USE, + translation_key="total_watering_time", + icon="mdi:history", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + *BATTERY_SENSORS, + ), # Water Detector # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli "sj": BATTERY_SENSORS, diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 9c807419551..e9b13e10a95 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -421,6 +421,19 @@ "4": "Mood 4", "5": "Mood 5" } + }, + "weather_delay": { + "name": "Weather delay", + "state": { + "cancel": "Cancel", + "24h": "24h", + "48h": "48h", + "72h": "72h", + "96h": "96h", + "120h": "120h", + "144h": "144h", + "168h": "168h" + } } }, "sensor": { @@ -556,6 +569,9 @@ "water_level": { "name": "Water level" }, + "total_watering_time": { + "name": "Total watering time" + }, "filter_utilization": { "name": "Filter utilization" }, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index a48d797555c..ba304b4069e 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -430,6 +430,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Water Timer + "sfkzq": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + icon="mdi:sprinkler-variant", + ), + ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( From 634785a2d85597b0a6b3cac49c887ceb753ecdd6 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 29 Nov 2023 10:02:49 +0100 Subject: [PATCH 215/643] Atag: add host field description (#104691) --- homeassistant/components/atag/strings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/atag/strings.json b/homeassistant/components/atag/strings.json index 39ed972524d..82070c0209f 100644 --- a/homeassistant/components/atag/strings.json +++ b/homeassistant/components/atag/strings.json @@ -2,10 +2,13 @@ "config": { "step": { "user": { - "title": "Connect to the device", + "description": "Connect to the device", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Atag device." } } }, From afc3f1d933621cd0c522ce6f2ea5e61bc1ec5af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 29 Nov 2023 11:07:54 +0200 Subject: [PATCH 216/643] Make huawei_lte operator search and preferred network modes translatable (#104673) --- homeassistant/components/huawei_lte/sensor.py | 20 +------------------ .../components/huawei_lte/strings.json | 19 +++++++++++++++--- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index ee7256340df..ca3734bb305 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -8,8 +8,6 @@ from datetime import datetime, timedelta import logging import re -from huawei_lte_api.enums.net import NetworkModeEnum - from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, @@ -575,10 +573,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "State": HuaweiSensorEntityDescription( key="State", translation_key="operator_search_mode", - format_fn=lambda x: ( - {"0": "Auto", "1": "Manual"}.get(x), - None, - ), entity_category=EntityCategory.DIAGNOSTIC, ), }, @@ -588,19 +582,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "NetworkMode": HuaweiSensorEntityDescription( key="NetworkMode", - translation_key="preferred_mode", - format_fn=lambda x: ( - { - NetworkModeEnum.MODE_AUTO.value: "4G/3G/2G", - NetworkModeEnum.MODE_4G_3G_AUTO.value: "4G/3G", - NetworkModeEnum.MODE_4G_2G_AUTO.value: "4G/2G", - NetworkModeEnum.MODE_4G_ONLY.value: "4G", - NetworkModeEnum.MODE_3G_2G_AUTO.value: "3G/2G", - NetworkModeEnum.MODE_3G_ONLY.value: "3G", - NetworkModeEnum.MODE_2G_ONLY.value: "2G", - }.get(x), - None, - ), + translation_key="preferred_network_mode", entity_category=EntityCategory.DIAGNOSTIC, ), }, diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 9e46ca742b8..754f192e57e 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -231,10 +231,23 @@ "name": "Operator code" }, "operator_search_mode": { - "name": "Operator search mode" + "name": "Operator search mode", + "state": { + "0": "Auto", + "1": "Manual" + } }, - "preferred_mode": { - "name": "Preferred mode" + "preferred_network_mode": { + "name": "Preferred network mode", + "state": { + "00": "4G/3G/2G auto", + "0302": "4G/3G auto", + "0301": "4G/2G auto", + "03": "4G only", + "0201": "3G/2G auto", + "02": "3G only", + "01": "2G only" + } }, "sms_deleted_device": { "name": "SMS deleted (device)" From efd330f18222c463b9a61babbc7a4c58be7128c1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 10:47:23 +0100 Subject: [PATCH 217/643] Send localization info on websocket_api script errors (#104638) * Send localization info on script errors * Use connection exception hander * Keep HomeAssistantError is unknown_error * Move specific exception handling --- .../components/websocket_api/__init__.py | 1 + .../components/websocket_api/commands.py | 20 ++++++- .../components/websocket_api/connection.py | 20 ++++++- tests/common.py | 3 + .../components/websocket_api/test_commands.py | 59 +++++++++++++++++++ .../websocket_api/test_connection.py | 6 ++ 6 files changed, 106 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index 9c2645aec57..f7086cc81db 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -17,6 +17,7 @@ from .const import ( # noqa: F401 ERR_INVALID_FORMAT, ERR_NOT_FOUND, ERR_NOT_SUPPORTED, + ERR_SERVICE_VALIDATION_ERROR, ERR_TEMPLATE_ERROR, ERR_TIMEOUT, ERR_UNAUTHORIZED, diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 18688914e8b..5edf5018938 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -778,7 +778,25 @@ async def handle_execute_script( context = connection.context(msg) script_obj = Script(hass, script_config, f"{const.DOMAIN} script", const.DOMAIN) - script_result = await script_obj.async_run(msg.get("variables"), context=context) + try: + script_result = await script_obj.async_run( + msg.get("variables"), context=context + ) + except ServiceValidationError as err: + connection.logger.error(err) + connection.logger.debug("", exc_info=err) + connection.send_error( + msg["id"], + const.ERR_SERVICE_VALIDATION_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + return + except Exception as exc: # pylint: disable=broad-except + connection.async_handle_exception(msg, exc) + return connection.send_result( msg["id"], { diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 4581b3be773..551d4b0d1fe 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -255,7 +255,10 @@ class ActiveConnection: log_handler = self.logger.error code = const.ERR_UNKNOWN_ERROR - err_message = None + err_message: str | None = None + translation_domain: str | None = None + translation_key: str | None = None + translation_placeholders: dict[str, Any] | None = None if isinstance(err, Unauthorized): code = const.ERR_UNAUTHORIZED @@ -268,6 +271,10 @@ class ActiveConnection: err_message = "Timeout" elif isinstance(err, HomeAssistantError): err_message = str(err) + code = const.ERR_UNKNOWN_ERROR + translation_domain = err.translation_domain + translation_key = err.translation_key + translation_placeholders = err.translation_placeholders # This if-check matches all other errors but also matches errors which # result in an empty message. In that case we will also log the stack @@ -276,7 +283,16 @@ class ActiveConnection: err_message = "Unknown error" log_handler = self.logger.exception - self.send_message(messages.error_message(msg["id"], code, err_message)) + self.send_message( + messages.error_message( + msg["id"], + code, + err_message, + translation_domain=translation_domain, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + ) if code: err_message += f" ({code})" diff --git a/tests/common.py b/tests/common.py index a4979c85853..b2fa53d28fb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -297,6 +297,7 @@ def async_mock_service( schema: vol.Schema | None = None, response: ServiceResponse = None, supports_response: SupportsResponse | None = None, + raise_exception: Exception | None = None, ) -> list[ServiceCall]: """Set up a fake service & return a calls log list to this service.""" calls = [] @@ -305,6 +306,8 @@ def async_mock_service( def mock_service_log(call): # pylint: disable=unnecessary-lambda """Mock service call.""" calls.append(call) + if raise_exception is not None: + raise raise_exception return response if supports_response is None: diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index a9551310c2a..c573fb85bb1 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2317,6 +2317,65 @@ async def test_execute_script( assert call.context.as_dict() == msg_var["result"]["context"] +@pytest.mark.parametrize( + ("raise_exception", "err_code"), + [ + ( + HomeAssistantError( + "Some error", + translation_domain="test", + translation_key="test_error", + translation_placeholders={"option": "bla"}, + ), + "unknown_error", + ), + ( + ServiceValidationError( + "Some error", + translation_domain="test", + translation_key="test_error", + translation_placeholders={"option": "bla"}, + ), + "service_validation_error", + ), + ], +) +async def test_execute_script_err_localization( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + raise_exception: HomeAssistantError, + err_code: str, +) -> None: + """Test testing a condition.""" + async_mock_service( + hass, "domain_test", "test_service", raise_exception=raise_exception + ) + + await websocket_client.send_json( + { + "id": 5, + "type": "execute_script", + "sequence": [ + { + "service": "domain_test.test_service", + "data": {"hello": "world"}, + }, + {"stop": "done", "response_variable": "service_result"}, + ], + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + assert msg["error"]["code"] == err_code + assert msg["error"]["message"] == "Some error" + assert msg["error"]["translation_key"] == "test_error" + assert msg["error"]["translation_domain"] == "test" + assert msg["error"]["translation_placeholders"] == {"option": "bla"} + + async def test_execute_script_complex_response( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index da435d64d58..4bc736481c4 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -43,6 +43,12 @@ from tests.common import MockUser "Failed to do X", "Error handling message: Failed to do X (unknown_error) Mock User from 127.0.0.42 (Browser)", ), + ( + exceptions.ServiceValidationError("Failed to do X"), + websocket_api.ERR_UNKNOWN_ERROR, + "Failed to do X", + "Error handling message: Failed to do X (unknown_error) Mock User from 127.0.0.42 (Browser)", + ), ( ValueError("Really bad"), websocket_api.ERR_UNKNOWN_ERROR, From bcfb5307f5865e15dc81ebf969acab2a4de5e33e Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 29 Nov 2023 11:06:50 +0100 Subject: [PATCH 218/643] Balboa, Bond, Bosch: add host field description (#104695) --- homeassistant/components/balboa/strings.json | 5 ++++- homeassistant/components/bond/strings.json | 3 +++ homeassistant/components/bosch_shc/strings.json | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 238deb7d65d..101436c0f31 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -2,9 +2,12 @@ "config": { "step": { "user": { - "title": "Connect to the Balboa Wi-Fi device", + "description": "Connect to the Balboa Wi-Fi device", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Balboa Spa Wifi Device. For example, 192.168.1.58." } } }, diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index 4c7c224bc44..8986905c6ee 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -12,6 +12,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "The IP address of your Bond hub." } } }, diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 90688e1373f..88eb817bbd9 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -6,6 +6,9 @@ "title": "SHC authentication parameters", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bosch Smart Home Controller." } }, "credentials": { From 999875d0e45c7b1414b78b0d0939bf0631abf6d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Nov 2023 11:26:50 +0100 Subject: [PATCH 219/643] Autogenerate Dockerfile (#104669) --- Dockerfile | 3 ++ script/hassfest/__main__.py | 2 + script/hassfest/docker.py | 89 +++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 script/hassfest/docker.py diff --git a/Dockerfile b/Dockerfile index b61e1461c52..97eeb5b0dfa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,6 @@ +# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker ARG BUILD_FROM FROM ${BUILD_FROM} diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 32803731ecd..c454c69d141 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -16,6 +16,7 @@ from . import ( coverage, dependencies, dhcp, + docker, json, manifest, metadata, @@ -50,6 +51,7 @@ INTEGRATION_PLUGINS = [ ] HASS_PLUGINS = [ coverage, + docker, mypy_config, metadata, ] diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py new file mode 100644 index 00000000000..1849e8e7ec8 --- /dev/null +++ b/script/hassfest/docker.py @@ -0,0 +1,89 @@ +"""Generate and validate the dockerfile.""" +from homeassistant import core +from homeassistant.util import executor, thread + +from .model import Config, Integration + +DOCKERFILE_TEMPLATE = """# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker +ARG BUILD_FROM +FROM ${{BUILD_FROM}} + +# Synchronize with homeassistant/core.py:async_stop +ENV \\ + S6_SERVICES_GRACETIME={timeout} + +ARG QEMU_CPU + +WORKDIR /usr/src + +## Setup Home Assistant Core dependencies +COPY requirements.txt homeassistant/ +COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ +RUN \\ + pip3 install \\ + --only-binary=:all: \\ + -r homeassistant/requirements.txt + +COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ +RUN \\ + if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \\ + pip3 install homeassistant/home_assistant_frontend-*.whl; \\ + fi \\ + && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \\ + pip3 install homeassistant/home_assistant_intents-*.whl; \\ + fi \\ + && \\ + LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \\ + MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \\ + pip3 install \\ + --only-binary=:all: \\ + -r homeassistant/requirements_all.txt + +## Setup Home Assistant Core +COPY . homeassistant/ +RUN \\ + pip3 install \\ + --only-binary=:all: \\ + -e ./homeassistant \\ + && python3 -m compileall \\ + homeassistant/homeassistant + +# Home Assistant S6-Overlay +COPY rootfs / + +WORKDIR /config +""" + + +def _generate_dockerfile() -> str: + timeout = ( + core.STAGE_1_SHUTDOWN_TIMEOUT + + core.STAGE_2_SHUTDOWN_TIMEOUT + + core.STAGE_3_SHUTDOWN_TIMEOUT + + executor.EXECUTOR_SHUTDOWN_TIMEOUT + + thread.THREADING_SHUTDOWN_TIMEOUT + + 10 + ) + return DOCKERFILE_TEMPLATE.format(timeout=timeout * 1000) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate dockerfile.""" + dockerfile_content = _generate_dockerfile() + config.cache["dockerfile"] = dockerfile_content + + dockerfile_path = config.root / "Dockerfile" + if dockerfile_path.read_text() != dockerfile_content: + config.add_error( + "docker", + "File Dockerfile is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) + + +def generate(integrations: dict[str, Integration], config: Config) -> None: + """Generate dockerfile.""" + dockerfile_path = config.root / "Dockerfile" + dockerfile_path.write_text(config.cache["dockerfile"]) From bd8f01bd35d95789b49769dbc40dc51870f8a339 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Nov 2023 05:30:15 -0500 Subject: [PATCH 220/643] Bump ZHA dependencies (#104335) --- homeassistant/components/zha/__init__.py | 106 ++++++++++---- homeassistant/components/zha/config_flow.py | 26 ++-- homeassistant/components/zha/core/const.py | 2 +- homeassistant/components/zha/core/device.py | 2 +- homeassistant/components/zha/core/gateway.py | 96 ++++++------- homeassistant/components/zha/entity.py | 2 +- homeassistant/components/zha/manifest.json | 14 +- homeassistant/components/zha/radio_manager.py | 20 ++- requirements_all.txt | 14 +- requirements_test_all.txt | 14 +- .../test_silabs_multiprotocol_addon.py | 13 +- .../test_config_flow.py | 4 +- .../homeassistant_sky_connect/test_init.py | 8 +- .../homeassistant_yellow/test_config_flow.py | 4 +- .../homeassistant_yellow/test_init.py | 4 +- tests/components/zha/conftest.py | 6 +- tests/components/zha/test_config_flow.py | 97 +++++++------ tests/components/zha/test_gateway.py | 136 +++--------------- tests/components/zha/test_init.py | 79 ++++++++-- tests/components/zha/test_repairs.py | 6 + tests/components/zha/test_websocket_api.py | 18 ++- 21 files changed, 349 insertions(+), 322 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 222c7f1d4ef..2046070d6a5 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -9,12 +9,12 @@ import re import voluptuous as vol from zhaquirks import setup as setup_quirks from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH -from zigpy.exceptions import NetworkSettingsInconsistent +from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -29,6 +29,7 @@ from .core.const import ( CONF_CUSTOM_QUIRKS_PATH, CONF_DEVICE_CONFIG, CONF_ENABLE_QUIRKS, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, CONF_USB_PATH, CONF_ZIGPY, @@ -36,6 +37,8 @@ from .core.const import ( DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, + STARTUP_FAILURE_DELAY_S, + STARTUP_RETRIES, RadioType, ) from .core.device import get_device_automation_triggers @@ -158,42 +161,67 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) - zha_gateway = ZHAGateway(hass, zha_data.yaml_config, config_entry) + # Retry setup a few times before giving up to deal with missing serial ports in VMs + for attempt in range(STARTUP_RETRIES): + try: + zha_gateway = await ZHAGateway.async_from_config( + hass=hass, + config=zha_data.yaml_config, + config_entry=config_entry, + ) + break + except NetworkSettingsInconsistent as exc: + await warn_on_inconsistent_network_settings( + hass, + config_entry=config_entry, + old_state=exc.old_state, + new_state=exc.new_state, + ) + raise ConfigEntryError( + "Network settings do not match most recent backup" + ) from exc + except TransientConnectionError as exc: + raise ConfigEntryNotReady from exc + except Exception as exc: # pylint: disable=broad-except + _LOGGER.debug( + "Couldn't start coordinator (attempt %s of %s)", + attempt + 1, + STARTUP_RETRIES, + exc_info=exc, + ) - try: - await zha_gateway.async_initialize() - except NetworkSettingsInconsistent as exc: - await warn_on_inconsistent_network_settings( - hass, - config_entry=config_entry, - old_state=exc.old_state, - new_state=exc.new_state, - ) - raise HomeAssistantError( - "Network settings do not match most recent backup" - ) from exc - except Exception: - if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: - try: - await warn_on_wrong_silabs_firmware( - hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - ) - except AlreadyRunningEZSP as exc: - # If connecting fails but we somehow probe EZSP (e.g. stuck in the - # bootloader), reconnect, it should work - raise ConfigEntryNotReady from exc + if attempt < STARTUP_RETRIES - 1: + await asyncio.sleep(STARTUP_FAILURE_DELAY_S) + continue - raise + if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: + try: + # Ignore all exceptions during probing, they shouldn't halt setup + await warn_on_wrong_silabs_firmware( + hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + ) + except AlreadyRunningEZSP as ezsp_exc: + raise ConfigEntryNotReady from ezsp_exc + + raise repairs.async_delete_blocking_issues(hass) + manufacturer = zha_gateway.state.node_info.manufacturer + model = zha_gateway.state.node_info.model + + if manufacturer is None and model is None: + manufacturer = "Unknown" + model = "Unknown" + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.coordinator_ieee))}, - identifiers={(DOMAIN, str(zha_gateway.coordinator_ieee))}, + connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.state.node_info.ieee))}, + identifiers={(DOMAIN, str(zha_gateway.state.node_info.ieee))}, name="Zigbee Coordinator", - manufacturer="ZHA", - model=zha_gateway.radio_description, + manufacturer=manufacturer, + model=model, + sw_version=zha_gateway.state.node_info.version, ) websocket_api.async_load_api(hass) @@ -267,5 +295,23 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.version = 3 hass.config_entries.async_update_entry(config_entry, data=data) + if config_entry.version == 3: + data = {**config_entry.data} + + if not data[CONF_DEVICE].get(CONF_BAUDRATE): + data[CONF_DEVICE][CONF_BAUDRATE] = { + "deconz": 38400, + "xbee": 57600, + "ezsp": 57600, + "znp": 115200, + "zigate": 115200, + }[data[CONF_RADIO_TYPE]] + + if not data[CONF_DEVICE].get(CONF_FLOW_CONTROL): + data[CONF_DEVICE][CONF_FLOW_CONTROL] = None + + config_entry.version = 4 + hass.config_entries.async_update_entry(config_entry, data=data) + _LOGGER.info("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 1b6bbee5159..60cf917d9f6 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -27,12 +27,13 @@ from homeassistant.util import dt as dt_util from .core.const import ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN, RadioType, ) from .radio_manager import ( + DEVICE_SCHEMA, HARDWARE_DISCOVERY_SCHEMA, RECOMMENDED_RADIOS, ProbeResult, @@ -42,7 +43,7 @@ from .radio_manager import ( CONF_MANUAL_PATH = "Enter Manually" SUPPORTED_PORT_SETTINGS = ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, ) DECONZ_DOMAIN = "deconz" @@ -160,7 +161,7 @@ class BaseZhaFlow(FlowHandler): return self.async_create_entry( title=self._title, data={ - CONF_DEVICE: device_settings, + CONF_DEVICE: DEVICE_SCHEMA(device_settings), CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, }, ) @@ -281,7 +282,7 @@ class BaseZhaFlow(FlowHandler): for ( param, value, - ) in self._radio_mgr.radio_type.controller.SCHEMA_DEVICE.schema.items(): + ) in DEVICE_SCHEMA.schema.items(): if param not in SUPPORTED_PORT_SETTINGS: continue @@ -488,7 +489,7 @@ class BaseZhaFlow(FlowHandler): class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 3 + VERSION = 4 async def _set_unique_id_or_update_path( self, unique_id: str, device_path: str @@ -646,22 +647,17 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN name = discovery_data["name"] radio_type = self._radio_mgr.parse_radio_type(discovery_data["radio_type"]) - - try: - device_settings = radio_type.controller.SCHEMA_DEVICE( - discovery_data["port"] - ) - except vol.Invalid: - return self.async_abort(reason="invalid_hardware_data") + device_settings = discovery_data["port"] + device_path = device_settings[CONF_DEVICE_PATH] await self._set_unique_id_or_update_path( - unique_id=f"{name}_{radio_type.name}_{device_settings[CONF_DEVICE_PATH]}", - device_path=device_settings[CONF_DEVICE_PATH], + unique_id=f"{name}_{radio_type.name}_{device_path}", + device_path=device_path, ) self._title = name self._radio_mgr.radio_type = radio_type - self._radio_mgr.device_path = device_settings[CONF_DEVICE_PATH] + self._radio_mgr.device_path = device_path self._radio_mgr.device_settings = device_settings self.context["title_placeholders"] = {CONF_NAME: name} diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 9874fddc598..f89ed8d9a52 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -127,6 +127,7 @@ CONF_ALARM_FAILED_TRIES = "alarm_failed_tries" CONF_ALARM_ARM_REQUIRES_CODE = "alarm_arm_requires_code" CONF_BAUDRATE = "baudrate" +CONF_FLOW_CONTROL = "flow_control" CONF_CUSTOM_QUIRKS_PATH = "custom_quirks_path" CONF_DEFAULT_LIGHT_TRANSITION = "default_light_transition" CONF_DEVICE_CONFIG = "device_config" @@ -136,7 +137,6 @@ CONF_ALWAYS_PREFER_XY_COLOR_MODE = "always_prefer_xy_color_mode" CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" -CONF_FLOWCONTROL = "flow_control" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 44acbb172fc..0ce6f47b61e 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -285,7 +285,7 @@ class ZHADevice(LogMixin): if not self.is_coordinator: return False - return self.ieee == self.gateway.coordinator_ieee + return self.ieee == self.gateway.state.node_info.ieee @property def is_end_device(self) -> bool | None: diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index b4c02d33015..5c038a2d7f8 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -11,7 +11,7 @@ import itertools import logging import re import time -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, Self from zigpy.application import ControllerApplication from zigpy.config import ( @@ -24,15 +24,14 @@ from zigpy.config import ( ) import zigpy.device import zigpy.endpoint -from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError import zigpy.group +from zigpy.state import State from zigpy.types.named import EUI64 from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components.system_log import LogEntry, _figure_out_source from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -66,8 +65,6 @@ from .const import ( SIGNAL_ADD_ENTITIES, SIGNAL_GROUP_MEMBERSHIP_CHANGE, SIGNAL_REMOVE, - STARTUP_FAILURE_DELAY_S, - STARTUP_RETRIES, UNKNOWN_MANUFACTURER, UNKNOWN_MODEL, ZHA_GW_MSG, @@ -123,10 +120,6 @@ class DevicePairingStatus(Enum): class ZHAGateway: """Gateway that handles events that happen on the ZHA Zigbee network.""" - # -- Set in async_initialize -- - application_controller: ControllerApplication - radio_description: str - def __init__( self, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry ) -> None: @@ -135,7 +128,8 @@ class ZHAGateway: self._config = config self._devices: dict[EUI64, ZHADevice] = {} self._groups: dict[int, ZHAGroup] = {} - self.coordinator_zha_device: ZHADevice | None = None + self.application_controller: ControllerApplication = None + self.coordinator_zha_device: ZHADevice = None # type: ignore[assignment] self._device_registry: collections.defaultdict[ EUI64, list[EntityReference] ] = collections.defaultdict(list) @@ -147,13 +141,11 @@ class ZHAGateway: self._log_relay_handler = LogRelayHandler(hass, self) self.config_entry = config_entry self._unsubs: list[Callable[[], None]] = [] + self.shutting_down = False def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" - radio_type = self.config_entry.data[CONF_RADIO_TYPE] - - app_controller_cls = RadioType[radio_type].controller - self.radio_description = RadioType[radio_type].description + radio_type = RadioType[self.config_entry.data[CONF_RADIO_TYPE]] app_config = self._config.get(CONF_ZIGPY, {}) database = self._config.get( @@ -170,7 +162,7 @@ class ZHAGateway: # event loop, when a connection to a TCP coordinator fails in a specific way if ( CONF_USE_THREAD not in app_config - and RadioType[radio_type] is RadioType.ezsp + and radio_type is RadioType.ezsp and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") ): app_config[CONF_USE_THREAD] = False @@ -189,48 +181,40 @@ class ZHAGateway: ): app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 - return app_controller_cls, app_controller_cls.SCHEMA(app_config) + return radio_type.controller, radio_type.controller.SCHEMA(app_config) + + @classmethod + async def async_from_config( + cls, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry + ) -> Self: + """Create an instance of a gateway from config objects.""" + instance = cls(hass, config, config_entry) + await instance.async_initialize() + return instance async def async_initialize(self) -> None: """Initialize controller and connect radio.""" discovery.PROBE.initialize(self.hass) discovery.GROUP_PROBE.initialize(self.hass) + self.shutting_down = False + app_controller_cls, app_config = self.get_application_controller_data() - self.application_controller = await app_controller_cls.new( + app = await app_controller_cls.new( config=app_config, auto_form=False, start_radio=False, ) try: - for attempt in range(STARTUP_RETRIES): - try: - await self.application_controller.startup(auto_form=True) - except TransientConnectionError as exc: - raise ConfigEntryNotReady from exc - except NetworkSettingsInconsistent: - raise - except Exception as exc: # pylint: disable=broad-except - _LOGGER.debug( - "Couldn't start %s coordinator (attempt %s of %s)", - self.radio_description, - attempt + 1, - STARTUP_RETRIES, - exc_info=exc, - ) - - if attempt == STARTUP_RETRIES - 1: - raise exc - - await asyncio.sleep(STARTUP_FAILURE_DELAY_S) - else: - break + await app.startup(auto_form=True) except Exception: # Explicitly shut down the controller application on failure - await self.application_controller.shutdown() + await app.shutdown() raise + self.application_controller = app + zha_data = get_zha_data(self.hass) zha_data.gateway = self @@ -244,6 +228,17 @@ class ZHAGateway: self.application_controller.add_listener(self) self.application_controller.groups.add_listener(self) + def connection_lost(self, exc: Exception) -> None: + """Handle connection lost event.""" + if self.shutting_down: + return + + _LOGGER.debug("Connection to the radio was lost: %r", exc) + + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + def _find_coordinator_device(self) -> zigpy.device.Device: zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) @@ -258,6 +253,7 @@ class ZHAGateway: @callback def async_load_devices(self) -> None: """Restore ZHA devices from zigpy application state.""" + for zigpy_device in self.application_controller.devices.values(): zha_device = self._async_get_or_create_device(zigpy_device, restored=True) delta_msg = "not known" @@ -280,6 +276,7 @@ class ZHAGateway: @callback def async_load_groups(self) -> None: """Initialize ZHA groups.""" + for group_id in self.application_controller.groups: group = self.application_controller.groups[group_id] zha_group = self._async_get_or_create_group(group) @@ -521,9 +518,9 @@ class ZHAGateway: entity_registry.async_remove(entry.entity_id) @property - def coordinator_ieee(self) -> EUI64: - """Return the active coordinator's IEEE address.""" - return self.application_controller.state.node_info.ieee + def state(self) -> State: + """Return the active coordinator's network state.""" + return self.application_controller.state @property def devices(self) -> dict[EUI64, ZHADevice]: @@ -711,6 +708,7 @@ class ZHAGateway: group_id: int | None = None, ) -> ZHAGroup | None: """Create a new Zigpy Zigbee group.""" + # we start with two to fill any gaps from a user removing existing groups if group_id is None: @@ -758,19 +756,13 @@ class ZHAGateway: async def shutdown(self) -> None: """Stop ZHA Controller Application.""" _LOGGER.debug("Shutting down ZHA ControllerApplication") + self.shutting_down = True + for unsubscribe in self._unsubs: unsubscribe() for device in self.devices.values(): device.async_cleanup_handles() - # shutdown is called when the config entry unloads are processed - # there are cases where unloads are processed because of a failure of - # some sort and the application controller may not have been - # created yet - if ( - hasattr(self, "application_controller") - and self.application_controller is not None - ): - await self.application_controller.shutdown() + await self.application_controller.shutdown() def handle_message( self, diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 05e1da7c570..b92d077907f 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -92,7 +92,7 @@ class BaseZhaEntity(LogMixin, entity.Entity): manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, zha_gateway.coordinator_ieee), + via_device=(DOMAIN, zha_gateway.state.node_info.ieee), ) @callback diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 786caf1809c..cd53772777a 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,16 +21,16 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.8", + "bellows==0.37.1", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.107", - "zigpy-deconz==0.21.1", - "zigpy==0.59.0", - "zigpy-xbee==0.19.0", - "zigpy-zigate==0.11.0", - "zigpy-znp==0.11.6", - "universal-silabs-flasher==0.0.14", + "zigpy-deconz==0.22.0", + "zigpy==0.60.0", + "zigpy-xbee==0.20.0", + "zigpy-zigate==0.12.0", + "zigpy-znp==0.12.0", + "universal-silabs-flasher==0.0.15", "pyserial-asyncio-fast==0.11" ], "usb": [ diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index d20cf752a91..d3ca03de8d8 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -19,6 +19,7 @@ from zigpy.config import ( CONF_DEVICE, CONF_DEVICE_PATH, CONF_NWK_BACKUP_ENABLED, + SCHEMA_DEVICE, ) from zigpy.exceptions import NetworkNotFormed @@ -58,10 +59,21 @@ RETRY_DELAY_S = 1.0 BACKUP_RETRIES = 5 MIGRATION_RETRIES = 100 + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required("path"): str, + vol.Optional("baudrate", default=115200): int, + vol.Optional("flow_control", default=None): vol.In( + ["hardware", "software", None] + ), + } +) + HARDWARE_DISCOVERY_SCHEMA = vol.Schema( { vol.Required("name"): str, - vol.Required("port"): dict, + vol.Required("port"): DEVICE_SCHEMA, vol.Required("radio_type"): str, } ) @@ -204,9 +216,7 @@ class ZhaRadioManager: for radio in AUTOPROBE_RADIOS: _LOGGER.debug("Attempting to probe radio type %s", radio) - dev_config = radio.controller.SCHEMA_DEVICE( - {CONF_DEVICE_PATH: self.device_path} - ) + dev_config = SCHEMA_DEVICE({CONF_DEVICE_PATH: self.device_path}) probe_result = await radio.controller.probe(dev_config) if not probe_result: @@ -357,7 +367,7 @@ class ZhaMultiPANMigrationHelper: migration_data["new_discovery_info"]["radio_type"] ) - new_device_settings = new_radio_type.controller.SCHEMA_DEVICE( + new_device_settings = SCHEMA_DEVICE( migration_data["new_discovery_info"]["port"] ) diff --git a/requirements_all.txt b/requirements_all.txt index bce0550bb44..756de3bd306 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -523,7 +523,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.8 +bellows==0.37.1 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.3 @@ -2660,7 +2660,7 @@ unifi-discovery==1.1.7 unifiled==0.11 # homeassistant.components.zha -universal-silabs-flasher==0.0.14 +universal-silabs-flasher==0.0.15 # homeassistant.components.upb upb-lib==0.5.4 @@ -2828,19 +2828,19 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.21.1 +zigpy-deconz==0.22.0 # homeassistant.components.zha -zigpy-xbee==0.19.0 +zigpy-xbee==0.20.0 # homeassistant.components.zha -zigpy-zigate==0.11.0 +zigpy-zigate==0.12.0 # homeassistant.components.zha -zigpy-znp==0.11.6 +zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.59.0 +zigpy==0.60.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a58be624388..bbaaf1bcb16 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -445,7 +445,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.8 +bellows==0.37.1 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.3 @@ -1979,7 +1979,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.1.7 # homeassistant.components.zha -universal-silabs-flasher==0.0.14 +universal-silabs-flasher==0.0.15 # homeassistant.components.upb upb-lib==0.5.4 @@ -2117,19 +2117,19 @@ zeversolar==0.3.1 zha-quirks==0.0.107 # homeassistant.components.zha -zigpy-deconz==0.21.1 +zigpy-deconz==0.22.0 # homeassistant.components.zha -zigpy-xbee==0.19.0 +zigpy-xbee==0.20.0 # homeassistant.components.zha -zigpy-zigate==0.11.0 +zigpy-zigate==0.12.0 # homeassistant.components.zha -zigpy-znp==0.11.6 +zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.59.0 +zigpy==0.60.0 # homeassistant.components.zwave_js zwave-js-server-python==0.54.0 diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index fbc77cdee9e..f58d561bfb3 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -293,7 +293,14 @@ async def test_option_flow_install_multi_pan_addon_zha( config_entry.add_to_hass(hass) zha_config_entry = MockConfigEntry( - data={"device": {"path": "/dev/ttyTEST123"}, "radio_type": "ezsp"}, + data={ + "device": { + "path": "/dev/ttyTEST123", + "baudrate": 115200, + "flow_control": None, + }, + "radio_type": "ezsp", + }, domain=ZHA_DOMAIN, options={}, title="Test", @@ -348,8 +355,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 4d43d29463a..65636b27a16 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -337,8 +337,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index e00603dc8f7..11961c09a2d 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -147,7 +147,7 @@ async def test_setup_zha( assert config_entry.data == { "device": { "baudrate": 115200, - "flow_control": "software", + "flow_control": None, "path": CONFIG_ENTRY_DATA["device"], }, "radio_type": "ezsp", @@ -200,8 +200,8 @@ async def test_setup_zha_multipan( config_entry = hass.config_entries.async_entries("zha")[0] assert config_entry.data == { "device": { - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, "path": "socket://core-silabs-multiprotocol:9999", }, "radio_type": "ezsp", @@ -255,7 +255,7 @@ async def test_setup_zha_multipan_other_device( assert config_entry.data == { "device": { "baudrate": 115200, - "flow_control": "software", + "flow_control": None, "path": CONFIG_ENTRY_DATA["device"], }, "radio_type": "ezsp", diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 58d47c41987..242b316de66 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -249,8 +249,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index addc519c865..f8cdcd8a13b 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -145,8 +145,8 @@ async def test_setup_zha_multipan( config_entry = hass.config_entries.async_entries("zha")[0] assert config_entry.data == { "device": { - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, "path": "socket://core-silabs-multiprotocol:9999", }, "radio_type": "ezsp", diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index a4ff5a3b205..1b3a536007a 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -46,7 +46,7 @@ def disable_request_retry_delay(): with patch( "homeassistant.components.zha.core.cluster_handlers.RETRYABLE_REQUEST_DECORATOR", zigpy.util.retryable_request(tries=3, delay=0), - ): + ), patch("homeassistant.components.zha.STARTUP_FAILURE_DELAY_S", 0.01): yield @@ -83,8 +83,8 @@ class _FakeApp(ControllerApplication): async def permit_ncp(self, time_s: int = 60): pass - async def permit_with_key( - self, node: zigpy.types.EUI64, code: bytes, time_s: int = 60 + async def permit_with_link_key( + self, node: zigpy.types.EUI64, link_key: zigpy.types.KeyData, time_s: int = 60 ): pass diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 9ec8048ea03..883df4aba94 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -10,7 +10,7 @@ import pytest import serial.tools.list_ports from zigpy.backups import BackupManager import zigpy.config -from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, SCHEMA_DEVICE import zigpy.device from zigpy.exceptions import NetworkNotFormed import zigpy.types @@ -22,7 +22,7 @@ from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_ from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN, EZSP_OVERWRITE_EUI64, @@ -118,9 +118,7 @@ def mock_detect_radio_type( async def detect(self): self.radio_type = radio_type - self.device_settings = radio_type.controller.SCHEMA_DEVICE( - {CONF_DEVICE_PATH: self.device_path} - ) + self.device_settings = SCHEMA_DEVICE({CONF_DEVICE_PATH: self.device_path}) return ret @@ -181,7 +179,7 @@ async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: assert result3["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, CONF_DEVICE_PATH: "socket://192.168.1.200:6638", }, CONF_RADIO_TYPE: "znp", @@ -238,6 +236,8 @@ async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> Non assert result4["data"] == { CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "zigate", } @@ -287,7 +287,7 @@ async def test_efr32_via_zeroconf(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "ezsp", } @@ -304,7 +304,7 @@ async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.5:6638", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } }, ) @@ -328,7 +328,7 @@ async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE] == { CONF_DEVICE_PATH: "socket://192.168.1.22:6638", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } @@ -483,6 +483,8 @@ async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None assert result4["data"] == { "device": { "path": "/dev/ttyZIGBEE", + "baudrate": 115200, + "flow_control": None, }, CONF_RADIO_TYPE: "zigate", } @@ -555,7 +557,7 @@ async def test_discovery_via_usb_path_changes(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/ttyUSB1", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } }, ) @@ -579,7 +581,7 @@ async def test_discovery_via_usb_path_changes(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE] == { CONF_DEVICE_PATH: "/dev/ttyZIGBEE", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } @@ -754,6 +756,8 @@ async def test_user_flow(hass: HomeAssistant) -> None: assert result2["data"] == { "device": { "path": port.device, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "deconz", } @@ -773,7 +777,11 @@ async def test_user_flow_not_detected(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, - data={zigpy.config.CONF_DEVICE_PATH: port_select}, + data={ + zigpy.config.CONF_DEVICE_PATH: port_select, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, ) assert result["type"] == FlowResultType.FORM @@ -951,31 +959,6 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None: assert probe_mock.await_count == 1 -@pytest.mark.parametrize( - ("old_type", "new_type"), - [ - ("ezsp", "ezsp"), - ("ti_cc", "znp"), # only one that should change - ("znp", "znp"), - ("deconz", "deconz"), - ], -) -async def test_migration_ti_cc_to_znp( - old_type, new_type, hass: HomeAssistant, config_entry: MockConfigEntry -) -> None: - """Test zigpy-cc to zigpy-znp config migration.""" - config_entry.data = {**config_entry.data, CONF_RADIO_TYPE: old_type} - config_entry.version = 2 - config_entry.add_to_hass(hass) - - with patch("homeassistant.components.zha.async_setup_entry", return_value=True): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.version > 2 - assert config_entry.data[CONF_RADIO_TYPE] == new_type - - @pytest.mark.parametrize("onboarded", [True, False]) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_hardware(onboarded, hass: HomeAssistant) -> None: @@ -1022,7 +1005,7 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: assert result3["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: "hardware", + CONF_FLOW_CONTROL: "hardware", CONF_DEVICE_PATH: "/dev/ttyAMA1", }, CONF_RADIO_TYPE: "ezsp", @@ -1171,6 +1154,7 @@ async def test_formation_strategy_form_initial_network( @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_onboarding_auto_formation_new_hardware( mock_app, hass: HomeAssistant ) -> None: @@ -1577,7 +1561,7 @@ async def test_options_flow_defaults( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/ttyUSB0", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1645,7 +1629,7 @@ async def test_options_flow_defaults( # Change everything CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: "software", }, ) @@ -1668,7 +1652,7 @@ async def test_options_flow_defaults( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: "software", }, CONF_RADIO_TYPE: "znp", } @@ -1697,7 +1681,7 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://localhost:5678", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1766,7 +1750,7 @@ async def test_options_flow_restarts_running_zha_if_cancelled( CONF_DEVICE: { CONF_DEVICE_PATH: "socket://localhost:5678", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1821,7 +1805,7 @@ async def test_options_flow_migration_reset_old_adapter( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/serial/by-id/old_radio", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1954,3 +1938,28 @@ async def test_discovery_wrong_firmware_installed(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "wrong_firmware_installed" + + +@pytest.mark.parametrize( + ("old_type", "new_type"), + [ + ("ezsp", "ezsp"), + ("ti_cc", "znp"), # only one that should change + ("znp", "znp"), + ("deconz", "deconz"), + ], +) +async def test_migration_ti_cc_to_znp( + old_type: str, new_type: str, hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test zigpy-cc to zigpy-znp config migration.""" + config_entry.data = {**config_entry.data, CONF_RADIO_TYPE: old_type} + config_entry.version = 2 + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.zha.async_setup_entry", return_value=True): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.version > 2 + assert config_entry.data[CONF_RADIO_TYPE] == new_type diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 2a0a241c864..4f520920704 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -4,22 +4,21 @@ from unittest.mock import MagicMock, patch import pytest from zigpy.application import ControllerApplication -import zigpy.exceptions import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting -from homeassistant.components.zha.core.const import RadioType -from homeassistant.components.zha.core.device import ZHADevice +from homeassistant.components.zha.core.gateway import ZHAGateway from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .common import async_find_group_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.common import MockConfigEntry + IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" @@ -224,101 +223,6 @@ async def test_gateway_create_group_with_id( assert zha_group.group_id == 0x1234 -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", - MagicMock(), -) -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups", - MagicMock(), -) -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -@pytest.mark.parametrize( - "startup_effect", - [ - [asyncio.TimeoutError(), FileNotFoundError(), None], - [asyncio.TimeoutError(), None], - [None], - ], -) -async def test_gateway_initialize_success( - startup_effect: list[Exception | None], - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA initializing the gateway successfully.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = startup_effect - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ): - await zha_gateway.async_initialize() - - assert zigpy_app_controller.startup.call_count == len(startup_effect) - device_light_1.async_cleanup_handles() - - -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -async def test_gateway_initialize_failure( - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA failing to initialize the gateway.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = [ - asyncio.TimeoutError(), - RuntimeError(), - FileNotFoundError(), - ] - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ), pytest.raises(FileNotFoundError): - await zha_gateway.async_initialize() - - assert zigpy_app_controller.startup.call_count == 3 - - -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -async def test_gateway_initialize_failure_transient( - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA failing to initialize the gateway but with a transient error.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = [ - RuntimeError(), - zigpy.exceptions.TransientConnectionError(), - ] - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ), pytest.raises(ConfigEntryNotReady): - await zha_gateway.async_initialize() - - # Initialization immediately stops and is retried after TransientConnectionError - assert zigpy_app_controller.startup.call_count == 2 - - @patch( "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", MagicMock(), @@ -340,22 +244,25 @@ async def test_gateway_initialize_bellows_thread( thread_state: bool, config_override: dict, hass: HomeAssistant, - coordinator: ZHADevice, zigpy_app_controller: ControllerApplication, + config_entry: MockConfigEntry, ) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None + config_entry.data = dict(config_entry.data) + config_entry.data["device"]["path"] = device_path + config_entry.add_to_hass(hass) - zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) - zha_gateway.config_entry.data["device"]["path"] = device_path - zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) - await zha_gateway.async_initialize() + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_new: + await zha_gateway.async_initialize() - RadioType.ezsp.controller.new.mock_calls[-1].kwargs["config"][ - "use_thread" - ] is thread_state + mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state + + await zha_gateway.shutdown() @pytest.mark.parametrize( @@ -373,15 +280,14 @@ async def test_gateway_force_multi_pan_channel( config_override: dict, expected_channel: int | None, hass: HomeAssistant, - coordinator, + config_entry: MockConfigEntry, ) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None + config_entry.data = dict(config_entry.data) + config_entry.data["device"]["path"] = device_path + config_entry.add_to_hass(hass) - zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) - zha_gateway.config_entry.data["device"]["path"] = device_path - zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) _, config = zha_gateway.get_application_controller_data() assert config["network"]["channel"] == expected_channel diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index ad6ab4e351e..c2e9469c239 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -1,5 +1,6 @@ """Tests for ZHA integration init.""" import asyncio +import typing from unittest.mock import AsyncMock, Mock, patch import pytest @@ -9,6 +10,7 @@ from zigpy.exceptions import TransientConnectionError from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, CONF_USB_PATH, DOMAIN, @@ -61,9 +63,8 @@ async def test_migration_from_v1_no_baudrate( assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE assert CONF_DEVICE in config_entry_v1.data assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH - assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] assert CONF_USB_PATH not in config_entry_v1.data - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -80,7 +81,7 @@ async def test_migration_from_v1_with_baudrate( assert CONF_USB_PATH not in config_entry_v1.data assert CONF_BAUDRATE in config_entry_v1.data[CONF_DEVICE] assert config_entry_v1.data[CONF_DEVICE][CONF_BAUDRATE] == 115200 - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -95,8 +96,7 @@ async def test_migration_from_v1_wrong_baudrate( assert CONF_DEVICE in config_entry_v1.data assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH assert CONF_USB_PATH not in config_entry_v1.data - assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @pytest.mark.skipif( @@ -149,23 +149,74 @@ async def test_setup_with_v3_cleaning_uri( mock_zigpy_connect: ControllerApplication, ) -> None: """Test migration of config entry from v3, applying corrections to the port path.""" - config_entry_v3 = MockConfigEntry( + config_entry_v4 = MockConfigEntry( domain=DOMAIN, data={ CONF_RADIO_TYPE: DATA_RADIO_TYPE, - CONF_DEVICE: {CONF_DEVICE_PATH: path, CONF_BAUDRATE: 115200}, + CONF_DEVICE: { + CONF_DEVICE_PATH: path, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, }, - version=3, + version=4, ) - config_entry_v3.add_to_hass(hass) + config_entry_v4.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry_v3.entry_id) + await hass.config_entries.async_setup(config_entry_v4.entry_id) await hass.async_block_till_done() - await hass.config_entries.async_unload(config_entry_v3.entry_id) + await hass.config_entries.async_unload(config_entry_v4.entry_id) - assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE - assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path - assert config_entry_v3.version == 3 + assert config_entry_v4.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert config_entry_v4.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path + assert config_entry_v4.version == 4 + + +@pytest.mark.parametrize( + ( + "radio_type", + "old_baudrate", + "old_flow_control", + "new_baudrate", + "new_flow_control", + ), + [ + ("znp", None, None, 115200, None), + ("znp", None, "software", 115200, "software"), + ("znp", 57600, "software", 57600, "software"), + ("deconz", None, None, 38400, None), + ("deconz", 115200, None, 115200, None), + ], +) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_baudrate_and_flow_control( + radio_type: str, + old_baudrate: int, + old_flow_control: typing.Literal["hardware", "software", None], + new_baudrate: int, + new_flow_control: typing.Literal["hardware", "software", None], + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test baudrate and flow control migration.""" + config_entry.data = { + **config_entry.data, + CONF_RADIO_TYPE: radio_type, + CONF_DEVICE: { + CONF_BAUDRATE: old_baudrate, + CONF_FLOW_CONTROL: old_flow_control, + CONF_DEVICE_PATH: "/dev/null", + }, + } + config_entry.version = 3 + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.version > 3 + assert config_entry.data[CONF_DEVICE][CONF_BAUDRATE] == new_baudrate + assert config_entry.data[CONF_DEVICE][CONF_FLOW_CONTROL] == new_flow_control @patch( diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index 9c79578843c..d168e2e57b1 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -95,6 +95,7 @@ def test_detect_radio_hardware_failure(hass: HomeAssistant) -> None: assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.OTHER +@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) @pytest.mark.parametrize( ("detected_hardware", "expected_learn_more_url"), [ @@ -188,6 +189,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure( assert issue is None +@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) async def test_multipan_firmware_retry_on_probe_ezsp( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -312,6 +314,8 @@ async def test_inconsistent_settings_keep_new( data = await resp.json() assert data["type"] == "create_entry" + await hass.config_entries.async_unload(config_entry.entry_id) + assert ( issue_registry.async_get_issue( domain=DOMAIN, @@ -388,6 +392,8 @@ async def test_inconsistent_settings_restore_old( data = await resp.json() assert data["type"] == "create_entry" + await hass.config_entries.async_unload(config_entry.entry_id) + assert ( issue_registry.async_get_issue( domain=DOMAIN, diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index d914c88c0c2..44006ea6ca1 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -62,7 +62,7 @@ from .conftest import ( ) from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS -from tests.common import MockUser +from tests.common import MockConfigEntry, MockUser IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" @@ -295,10 +295,12 @@ async def test_get_zha_config_with_alarm( async def test_update_zha_config( - zha_client, app_controller: ControllerApplication + hass: HomeAssistant, + config_entry: MockConfigEntry, + zha_client, + app_controller: ControllerApplication, ) -> None: """Test updating ZHA custom configuration.""" - configuration: dict = deepcopy(CONFIG_WITH_ALARM_OPTIONS) configuration["data"]["zha_options"]["default_light_transition"] = 10 @@ -312,10 +314,12 @@ async def test_update_zha_config( msg = await zha_client.receive_json() assert msg["success"] - await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) - msg = await zha_client.receive_json() - configuration = msg["result"] - assert configuration == configuration + await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) + msg = await zha_client.receive_json() + configuration = msg["result"] + assert configuration == configuration + + await hass.config_entries.async_unload(config_entry.entry_id) async def test_device_not_found(zha_client) -> None: From 6dc818b6822ce743eaf91d5f7fd2487c2047aa5a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 11:38:23 +0100 Subject: [PATCH 221/643] Add proj dependency to our wheels builder (#104699) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3b23f1b5b05..be9d393d9fd 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -199,7 +199,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From d9c0acc1d27e8991b06afdf0fcdbbc580519b658 Mon Sep 17 00:00:00 2001 From: Stefan Rado <628587+kroimon@users.noreply.github.com> Date: Wed, 29 Nov 2023 11:45:15 +0100 Subject: [PATCH 222/643] Partially revert #103807: Remove deprecated aux heat support from ESPHome climate entities (#104694) --- homeassistant/components/esphome/climate.py | 16 ----- tests/components/esphome/test_climate.py | 66 +-------------------- 2 files changed, 1 insertion(+), 81 deletions(-) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 73b326204b5..08ed2f1109d 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -173,8 +173,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti features |= ClimateEntityFeature.TARGET_TEMPERATURE if self._static_info.supports_target_humidity: features |= ClimateEntityFeature.TARGET_HUMIDITY - if self._static_info.supports_aux_heat: - features |= ClimateEntityFeature.AUX_HEAT if self.preset_modes: features |= ClimateEntityFeature.PRESET_MODE if self.fan_modes: @@ -272,12 +270,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti """Return the humidity we try to reach.""" return round(self._state.target_humidity) - @property - @esphome_state_property - def is_aux_heat(self) -> bool: - """Return the auxiliary heater state.""" - return self._state.aux_heat - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature (and operation mode if set).""" data: dict[str, Any] = {"key": self._key} @@ -326,11 +318,3 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti await self._client.climate_command( key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode) ) - - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - await self._client.climate_command(key=self._key, aux_heat=True) - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - await self._client.climate_command(key=self._key, aux_heat=False) diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 8f0b8f96c56..065890fd623 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -15,7 +15,6 @@ from aioesphomeapi import ( ) from homeassistant.components.climate import ( - ATTR_AUX_HEAT, ATTR_CURRENT_HUMIDITY, ATTR_FAN_MODE, ATTR_HUMIDITY, @@ -29,7 +28,6 @@ from homeassistant.components.climate import ( ATTR_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, FAN_HIGH, - SERVICE_SET_AUX_HEAT, SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, @@ -39,7 +37,7 @@ from homeassistant.components.climate import ( SWING_BOTH, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -321,68 +319,6 @@ async def test_climate_entity_with_step_and_target_temp( mock_client.climate_command.reset_mock() -async def test_climate_entity_with_aux_heat( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry -) -> None: - """Test a generic climate entity with aux heat.""" - entity_info = [ - ClimateInfo( - object_id="myclimate", - key=1, - name="my climate", - unique_id="my_climate", - supports_current_temperature=True, - supports_two_point_target_temperature=True, - supports_action=True, - visual_min_temperature=10.0, - visual_max_temperature=30.0, - supports_aux_heat=True, - ) - ] - states = [ - ClimateState( - key=1, - mode=ClimateMode.HEAT, - action=ClimateAction.HEATING, - current_temperature=30, - target_temperature=20, - fan_mode=ClimateFanMode.AUTO, - swing_mode=ClimateSwingMode.BOTH, - aux_heat=True, - ) - ] - user_service = [] - await mock_generic_device_entry( - mock_client=mock_client, - entity_info=entity_info, - user_service=user_service, - states=states, - ) - state = hass.states.get("climate.test_myclimate") - assert state is not None - assert state.state == HVACMode.HEAT - attributes = state.attributes - assert attributes[ATTR_AUX_HEAT] == STATE_ON - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_AUX_HEAT: False}, - blocking=True, - ) - mock_client.climate_command.assert_has_calls([call(key=1, aux_heat=False)]) - mock_client.climate_command.reset_mock() - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_AUX_HEAT: True}, - blocking=True, - ) - mock_client.climate_command.assert_has_calls([call(key=1, aux_heat=True)]) - mock_client.climate_command.reset_mock() - - async def test_climate_entity_with_humidity( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry ) -> None: From 31cab5803c36ce7650dd89510b2dbf0283111c0b Mon Sep 17 00:00:00 2001 From: schelv <13403863+schelv@users.noreply.github.com> Date: Wed, 29 Nov 2023 12:25:06 +0100 Subject: [PATCH 223/643] Add Option For Kelvin Unit To Color Temperature Selector (#103799) --- homeassistant/components/light/services.yaml | 15 +++++------ homeassistant/helpers/selector.py | 28 ++++++++++++++++++-- tests/helpers/test_selector.py | 10 +++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 433da53a570..fb7a1539944 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -252,8 +252,9 @@ turn_on: - light.ColorMode.RGBWW selector: color_temp: - min_mireds: 153 - max_mireds: 500 + unit: "mired" + min: 153 + max: 500 kelvin: filter: attribute: @@ -266,11 +267,10 @@ turn_on: - light.ColorMode.RGBWW advanced: true selector: - number: + color_temp: + unit: "kelvin" min: 2000 max: 6500 - step: 100 - unit_of_measurement: K brightness: filter: attribute: @@ -637,11 +637,10 @@ toggle: - light.ColorMode.RGBWW advanced: true selector: - number: + color_temp: + unit: "kelvin" min: 2000 max: 6500 - step: 100 - unit_of_measurement: K brightness: filter: attribute: diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index ac5166911ff..bda2440cfb3 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -425,10 +425,20 @@ class ColorRGBSelector(Selector[ColorRGBSelectorConfig]): class ColorTempSelectorConfig(TypedDict, total=False): """Class to represent a color temp selector config.""" + unit: ColorTempSelectorUnit + min: int + max: int max_mireds: int min_mireds: int +class ColorTempSelectorUnit(StrEnum): + """Possible units for a color temperature selector.""" + + KELVIN = "kelvin" + MIRED = "mired" + + @SELECTORS.register("color_temp") class ColorTempSelector(Selector[ColorTempSelectorConfig]): """Selector of an color temperature.""" @@ -437,6 +447,11 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { + vol.Optional("unit", default=ColorTempSelectorUnit.MIRED): vol.All( + vol.Coerce(ColorTempSelectorUnit), lambda val: val.value + ), + vol.Optional("min"): vol.Coerce(int), + vol.Optional("max"): vol.Coerce(int), vol.Optional("max_mireds"): vol.Coerce(int), vol.Optional("min_mireds"): vol.Coerce(int), } @@ -448,11 +463,20 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): def __call__(self, data: Any) -> int: """Validate the passed selection.""" + range_min = self.config.get("min") + range_max = self.config.get("max") + + if not range_min: + range_min = self.config.get("min_mireds") + + if not range_max: + range_max = self.config.get("max_mireds") + value: int = vol.All( vol.Coerce(float), vol.Range( - min=self.config.get("min_mireds"), - max=self.config.get("max_mireds"), + min=range_min, + max=range_max, ), )(data) return value diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 1e449fd103a..93c342384fd 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -907,6 +907,16 @@ def test_rgb_color_selector_schema( (100, 200), (99, 201), ), + ( + {"unit": "mired", "min": 100, "max": 200}, + (100, 200), + (99, 201), + ), + ( + {"unit": "kelvin", "min": 1000, "max": 2000}, + (1000, 2000), + (999, 2001), + ), ), ) def test_color_tempselector_schema( From fc7b17d35b42e640499067e2e00a27b503c25540 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 29 Nov 2023 12:33:25 +0100 Subject: [PATCH 224/643] Handle server disconnection for Comelit devices (#104583) --- homeassistant/components/comelit/coordinator.py | 4 ++-- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index d3bc973429b..1573d5cb627 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -68,13 +68,13 @@ class ComelitSerialBridge(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Update device data.""" _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) + try: await self.api.login() + return await self.api.get_all_devices() except exceptions.CannotConnect as err: _LOGGER.warning("Connection error for %s", self._host) await self.api.close() raise UpdateFailed(f"Error fetching data: {repr(err)}") from err except exceptions.CannotAuthenticate: raise ConfigEntryAuthFailed - - return await self.api.get_all_devices() diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 77796ac7e7f..89157b54255 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.5.2"] + "requirements": ["aiocomelit==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 756de3bd306..fe298d256da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -212,7 +212,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.5.2 +aiocomelit==0.6.2 # homeassistant.components.dhcp aiodiscover==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbaaf1bcb16..6c8a9e7d10c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -191,7 +191,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.5.2 +aiocomelit==0.6.2 # homeassistant.components.dhcp aiodiscover==1.5.1 From 9741380cc09592b74eb34da11f66e48f4706a05f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 12:41:47 +0100 Subject: [PATCH 225/643] Add proj-util dependency to our wheels builder (#104708) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index be9d393d9fd..01cdf008917 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -199,7 +199,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From 861bb48ab6781436cbb214169f97bd4cdc666330 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 13:07:52 +0100 Subject: [PATCH 226/643] Assign specific error code for HomeAssistantError on websocket_api connection exceptions (#104700) Assign specific error code for HomeAssistantError --- homeassistant/components/websocket_api/connection.py | 2 +- .../components/application_credentials/test_init.py | 2 +- tests/components/blueprint/test_websocket_api.py | 4 ++-- tests/components/config/test_device_registry.py | 12 ++++++------ tests/components/fritzbox/test_init.py | 2 +- tests/components/group/test_config_flow.py | 2 +- tests/components/input_select/test_init.py | 4 ++-- tests/components/tag/test_init.py | 2 +- tests/components/template/test_config_flow.py | 2 +- tests/components/websocket_api/test_commands.py | 2 +- tests/components/websocket_api/test_connection.py | 8 ++++---- 11 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 551d4b0d1fe..25b6c90d1ba 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -271,7 +271,7 @@ class ActiveConnection: err_message = "Timeout" elif isinstance(err, HomeAssistantError): err_message = str(err) - code = const.ERR_UNKNOWN_ERROR + code = const.ERR_HOME_ASSISTANT_ERROR translation_domain = err.translation_domain translation_key = err.translation_key translation_placeholders = err.translation_placeholders diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index cc56894cf0d..807eff4ef8d 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -479,7 +479,7 @@ async def test_config_flow( resp = await client.cmd("delete", {"application_credentials_id": ID}) assert not resp.get("success") assert "error" in resp - assert resp["error"].get("code") == "unknown_error" + assert resp["error"].get("code") == "home_assistant_error" assert ( resp["error"].get("message") == "Cannot delete credential in use by integration fake_integration" diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 213dff89597..b0439896c25 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -438,7 +438,7 @@ async def test_delete_blueprint_in_use_by_automation( assert msg["id"] == 9 assert not msg["success"] assert msg["error"] == { - "code": "unknown_error", + "code": "home_assistant_error", "message": "Blueprint in use", } @@ -484,6 +484,6 @@ async def test_delete_blueprint_in_use_by_script( assert msg["id"] == 9 assert not msg["success"] assert msg["error"] == { - "code": "unknown_error", + "code": "home_assistant_error", "message": "Blueprint in use", } diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 87bb9cc9409..4a784a6eff1 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -242,7 +242,7 @@ async def test_remove_config_entry_from_device( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" # Make async_remove_config_entry_device return True can_remove = True @@ -365,7 +365,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown config entry" # Try removing a config entry which does not support removal from the device @@ -380,7 +380,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert ( response["error"]["message"] == "Config entry does not support device removal" ) @@ -397,7 +397,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown device" # Try removing a config entry from a device which it's not connected to @@ -428,7 +428,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Config entry not in device" # Try removing a config entry which can't be loaded from a device - allowed @@ -443,5 +443,5 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Integration not found" diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 5c8d30772f0..b8273204325 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -296,7 +296,7 @@ async def test_remove_device( ) response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" await hass.async_block_till_done() # try to delete orphan_device diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 3189e344c62..7b83ed9eb0d 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -699,4 +699,4 @@ async def test_option_flow_sensor_preview_config_entry_removed( ) msg = await client.receive_json() assert not msg["success"] - assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} + assert msg["error"] == {"code": "home_assistant_error", "message": "Unknown error"} diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index 03c503ae494..3978d0cf175 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -740,7 +740,7 @@ async def test_update_duplicates( ) resp = await client.receive_json() assert not resp["success"] - assert resp["error"]["code"] == "unknown_error" + assert resp["error"]["code"] == "home_assistant_error" assert resp["error"]["message"] == "Duplicate options are not allowed" state = hass.states.get(input_entity_id) @@ -812,7 +812,7 @@ async def test_ws_create_duplicates( ) resp = await client.receive_json() assert not resp["success"] - assert resp["error"]["code"] == "unknown_error" + assert resp["error"]["code"] == "home_assistant_error" assert resp["error"]["message"] == "Duplicate options are not allowed" assert not hass.states.get(input_entity_id) diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index 3e034d2b9f2..5d54f31b13a 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -131,5 +131,5 @@ async def test_tag_id_exists( await client.send_json({"id": 2, "type": f"{DOMAIN}/create", "tag_id": "test tag"}) response = await client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert len(changes) == 0 diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index f4cfe90b9f0..b95a68afd85 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -845,4 +845,4 @@ async def test_option_flow_sensor_preview_config_entry_removed( ) msg = await client.receive_json() assert not msg["success"] - assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} + assert msg["error"] == {"code": "home_assistant_error", "message": "Unknown error"} diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c573fb85bb1..127b45484be 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2327,7 +2327,7 @@ async def test_execute_script( translation_key="test_error", translation_placeholders={"option": "bla"}, ), - "unknown_error", + "home_assistant_error", ), ( ServiceValidationError( diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index 4bc736481c4..80936d30752 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -39,15 +39,15 @@ from tests.common import MockUser ), ( exceptions.HomeAssistantError("Failed to do X"), - websocket_api.ERR_UNKNOWN_ERROR, + websocket_api.ERR_HOME_ASSISTANT_ERROR, "Failed to do X", - "Error handling message: Failed to do X (unknown_error) Mock User from 127.0.0.42 (Browser)", + "Error handling message: Failed to do X (home_assistant_error) Mock User from 127.0.0.42 (Browser)", ), ( exceptions.ServiceValidationError("Failed to do X"), - websocket_api.ERR_UNKNOWN_ERROR, + websocket_api.ERR_HOME_ASSISTANT_ERROR, "Failed to do X", - "Error handling message: Failed to do X (unknown_error) Mock User from 127.0.0.42 (Browser)", + "Error handling message: Failed to do X (home_assistant_error) Mock User from 127.0.0.42 (Browser)", ), ( ValueError("Really bad"), From 5f44dadb66068a04982340d8cce941e890f9536d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 29 Nov 2023 13:11:15 +0100 Subject: [PATCH 227/643] Rename todo due_date_time parameter to due_datetime (#104698) * Rename todo due_date_time parameter to due_datetime * Apply suggestions from code review --------- Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --- homeassistant/components/todo/__init__.py | 8 +++----- homeassistant/components/todo/const.py | 2 +- homeassistant/components/todo/services.yaml | 4 ++-- homeassistant/components/todo/strings.json | 8 ++++---- tests/components/local_todo/test_todo.py | 6 +++--- tests/components/todo/test_init.py | 14 +++++++------- 6 files changed, 20 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 814138dcb7f..c0e0303d76e 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -35,7 +35,7 @@ from .const import ( ATTR_DESCRIPTION, ATTR_DUE, ATTR_DUE_DATE, - ATTR_DUE_DATE_TIME, + ATTR_DUE_DATETIME, DOMAIN, TodoItemStatus, TodoListEntityFeature, @@ -73,7 +73,7 @@ TODO_ITEM_FIELDS = [ required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, ), TodoItemFieldDescription( - service_field=ATTR_DUE_DATE_TIME, + service_field=ATTR_DUE_DATETIME, validation=vol.All(cv.datetime, dt_util.as_local), todo_item_field=ATTR_DUE, required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, @@ -89,9 +89,7 @@ TODO_ITEM_FIELDS = [ TODO_ITEM_FIELD_SCHEMA = { vol.Optional(desc.service_field): desc.validation for desc in TODO_ITEM_FIELDS } -TODO_ITEM_FIELD_VALIDATIONS = [ - cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATE_TIME) -] +TODO_ITEM_FIELD_VALIDATIONS = [cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATETIME)] def _validate_supported_features( diff --git a/homeassistant/components/todo/const.py b/homeassistant/components/todo/const.py index 95e190cb3e3..a605f9fcba2 100644 --- a/homeassistant/components/todo/const.py +++ b/homeassistant/components/todo/const.py @@ -6,7 +6,7 @@ DOMAIN = "todo" ATTR_DUE = "due" ATTR_DUE_DATE = "due_date" -ATTR_DUE_DATE_TIME = "due_date_time" +ATTR_DUE_DATETIME = "due_datetime" ATTR_DESCRIPTION = "description" diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 390aa82753a..bc7da7db941 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -29,7 +29,7 @@ add_item: example: "2023-11-17" selector: date: - due_date_time: + due_datetime: example: "2023-11-17 13:30:00" selector: datetime: @@ -65,7 +65,7 @@ update_item: example: "2023-11-17" selector: date: - due_date_time: + due_datetime: example: "2023-11-17 13:30:00" selector: datetime: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index bca32f850eb..3da921a8f47 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -28,8 +28,8 @@ "name": "Due date", "description": "The date the to-do item is expected to be completed." }, - "due_date_time": { - "name": "Due date time", + "due_datetime": { + "name": "Due date and time", "description": "The date and time the to-do item is expected to be completed." }, "description": { @@ -58,8 +58,8 @@ "name": "Due date", "description": "The date the to-do item is expected to be completed." }, - "due_date_time": { - "name": "Due date time", + "due_datetime": { + "name": "Due date and time", "description": "The date and time the to-do item is expected to be completed." }, "description": { diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index b2c79ef4bd1..67d0703ca7c 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -71,7 +71,7 @@ def set_time_zone(hass: HomeAssistant) -> None: ({}, {}), ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), ( - {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due_datetime": "2023-11-17T11:30:00+00:00"}, {"due": "2023-11-17T05:30:00-06:00"}, ), ({"description": "Additional detail"}, {"description": "Additional detail"}), @@ -118,7 +118,7 @@ async def test_add_item( ({}, {}), ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), ( - {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due_datetime": "2023-11-17T11:30:00+00:00"}, {"due": "2023-11-17T05:30:00-06:00"}, ), ({"description": "Additional detail"}, {"description": "Additional detail"}), @@ -213,7 +213,7 @@ async def test_bulk_remove( ({"status": "completed"}, {"status": "completed"}, "0"), ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}, "1"), ( - {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due_datetime": "2023-11-17T11:30:00+00:00"}, {"due": "2023-11-17T05:30:00-06:00"}, "1", ), diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 0071d4ada86..90b06858e00 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -354,10 +354,10 @@ async def test_add_item_service_raises( ( { "item": "Submit forms", - "due_date_time": f"2023-11-17T17:00:00{TEST_OFFSET}", + "due_datetime": f"2023-11-17T17:00:00{TEST_OFFSET}", }, ValueError, - "does not support setting field 'due_date_time'", + "does not support setting field 'due_datetime'", ), ], ) @@ -396,7 +396,7 @@ async def test_add_item_service_invalid_input( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"item": "New item", "due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + {"item": "New item", "due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, TodoItem( summary="New item", status=TodoItemStatus.NEEDS_ACTION, @@ -405,7 +405,7 @@ async def test_add_item_service_invalid_input( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"item": "New item", "due_date_time": "2023-11-13T17:00:00+00:00"}, + {"item": "New item", "due_datetime": "2023-11-13T17:00:00+00:00"}, TodoItem( summary="New item", status=TodoItemStatus.NEEDS_ACTION, @@ -414,7 +414,7 @@ async def test_add_item_service_invalid_input( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"item": "New item", "due_date_time": "2023-11-13"}, + {"item": "New item", "due_datetime": "2023-11-13"}, TodoItem( summary="New item", status=TodoItemStatus.NEEDS_ACTION, @@ -663,7 +663,7 @@ async def test_update_item_service_invalid_input( @pytest.mark.parametrize( ("update_data"), [ - ({"due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}), + ({"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}), ({"due_date": "2023-11-13"}), ({"description": "Submit revised draft"}), ], @@ -697,7 +697,7 @@ async def test_update_todo_item_field_unsupported( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + {"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, TodoItem( uid="1", due=datetime.datetime(2023, 11, 13, 17, 0, 0, tzinfo=TEST_TIMEZONE), From cf23de1c488e3d7b89a76adf4cc26b5584a05ba6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 13:15:37 +0100 Subject: [PATCH 228/643] Add proj-dev dependency to our wheels builder (#104711) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 01cdf008917..9d16954cd09 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -199,7 +199,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From e5a7446afefb0d9dfd47b0fae803a7b7314f13e4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 29 Nov 2023 13:35:32 +0100 Subject: [PATCH 229/643] Use id as location data in Trafikverket Camera (#104473) --- .../trafikverket_camera/__init__.py | 43 +++++++++--- .../trafikverket_camera/config_flow.py | 18 ++--- .../trafikverket_camera/coordinator.py | 8 +-- .../trafikverket_camera/__init__.py | 7 +- .../trafikverket_camera/conftest.py | 6 +- .../trafikverket_camera/test_binary_sensor.py | 2 +- .../trafikverket_camera/test_camera.py | 8 +-- .../trafikverket_camera/test_config_flow.py | 20 +++--- .../trafikverket_camera/test_coordinator.py | 16 ++--- .../trafikverket_camera/test_init.py | 68 ++++++++++++++----- .../trafikverket_camera/test_recorder.py | 6 +- .../trafikverket_camera/test_sensor.py | 12 ++-- 12 files changed, 135 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index d9d28cfe13b..3ac3ce35882 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -6,7 +6,7 @@ import logging from pytrafikverket.trafikverket_camera import TrafikverketCamera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -42,13 +42,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" + api_key = entry.data[CONF_API_KEY] + web_session = async_get_clientsession(hass) + camera_api = TrafikverketCamera(web_session, api_key) # Change entry unique id from location to camera id if entry.version == 1: location = entry.data[CONF_LOCATION] - api_key = entry.data[CONF_API_KEY] - - web_session = async_get_clientsession(hass) - camera_api = TrafikverketCamera(web_session, api_key) try: camera_info = await camera_api.async_get_camera(location) @@ -60,14 +59,40 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if camera_id := camera_info.camera_id: entry.version = 2 - _LOGGER.debug( - "Migrate Trafikverket Camera config entry unique id to %s", - camera_id, - ) hass.config_entries.async_update_entry( entry, unique_id=f"{DOMAIN}-{camera_id}", ) + _LOGGER.debug( + "Migrated Trafikverket Camera config entry unique id to %s", + camera_id, + ) + else: + _LOGGER.error("Could not migrate the config entry. Camera has no id") + return False + + # Change entry data from location to id + if entry.version == 2: + location = entry.data[CONF_LOCATION] + + try: + camera_info = await camera_api.async_get_camera(location) + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Could not migrate the config entry. No connection to the api" + ) + return False + + if camera_id := camera_info.camera_id: + entry.version = 3 + _LOGGER.debug( + "Migrate Trafikverket Camera config entry unique id to %s", + camera_id, + ) + new_data = entry.data.copy() + new_data.pop(CONF_LOCATION) + new_data[CONF_ID] = camera_id + hass.config_entries.async_update_entry(entry, data=new_data) return True _LOGGER.error("Could not migrate the config entry. Camera has no id") return False diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index e75bc0bfa30..7572855b7d4 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -14,7 +14,7 @@ from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -25,7 +25,7 @@ from .const import CONF_LOCATION, DOMAIN class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Camera integration.""" - VERSION = 2 + VERSION = 3 entry: config_entries.ConfigEntry | None @@ -53,10 +53,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if camera_info: camera_id = camera_info.camera_id - if _location := camera_info.location: - camera_location = _location - else: - camera_location = camera_info.camera_name + camera_location = camera_info.camera_name or "Trafikverket Camera" return (errors, camera_location, camera_id) @@ -76,9 +73,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] assert self.entry is not None - errors, _, _ = await self.validate_input( - api_key, self.entry.data[CONF_LOCATION] - ) + errors, _, _ = await self.validate_input(api_key, self.entry.data[CONF_ID]) if not errors: self.hass.config_entries.async_update_entry( @@ -121,10 +116,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry( title=camera_location, - data={ - CONF_API_KEY: api_key, - CONF_LOCATION: camera_location, - }, + data={CONF_API_KEY: api_key, CONF_ID: camera_id}, ) return self.async_show_form( diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py index eb5a047ca73..8270fecd487 100644 --- a/homeassistant/components/trafikverket_camera/coordinator.py +++ b/homeassistant/components/trafikverket_camera/coordinator.py @@ -15,13 +15,13 @@ from pytrafikverket.exceptions import ( from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_LOCATION, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) TIME_BETWEEN_UPDATES = timedelta(minutes=5) @@ -48,14 +48,14 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]): ) self.session = async_get_clientsession(hass) self._camera_api = TrafikverketCamera(self.session, entry.data[CONF_API_KEY]) - self._location = entry.data[CONF_LOCATION] + self._id = entry.data[CONF_ID] async def _async_update_data(self) -> CameraData: """Fetch data from Trafikverket.""" camera_data: CameraInfo image: bytes | None = None try: - camera_data = await self._camera_api.async_get_camera(self._location) + camera_data = await self._camera_api.async_get_camera(self._id) except (NoCameraFound, MultipleCamerasFound, UnknownError) as error: raise UpdateFailed from error except InvalidAuthentication as error: diff --git a/tests/components/trafikverket_camera/__init__.py b/tests/components/trafikverket_camera/__init__.py index 026c122fb57..a9aa3ad70d1 100644 --- a/tests/components/trafikverket_camera/__init__.py +++ b/tests/components/trafikverket_camera/__init__.py @@ -2,9 +2,14 @@ from __future__ import annotations from homeassistant.components.trafikverket_camera.const import CONF_LOCATION -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_ID: "1234", +} + +ENTRY_CONFIG_OLD_CONFIG = { CONF_API_KEY: "1234567890", CONF_LOCATION: "Test location", } diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py index a4902ac2950..a5eeb707b34 100644 --- a/tests/components/trafikverket_camera/conftest.py +++ b/tests/components/trafikverket_camera/conftest.py @@ -32,9 +32,9 @@ async def load_integration_from_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) config_entry.add_to_hass(hass) @@ -54,7 +54,7 @@ def fixture_get_camera() -> CameraInfo: """Construct Camera Mock.""" return CameraInfo( - camera_name="Test_camera", + camera_name="Test Camera", camera_id="1234", active=True, deleted=False, diff --git a/tests/components/trafikverket_camera/test_binary_sensor.py b/tests/components/trafikverket_camera/test_binary_sensor.py index 6f7eb540289..87d0e6d58b7 100644 --- a/tests/components/trafikverket_camera/test_binary_sensor.py +++ b/tests/components/trafikverket_camera/test_binary_sensor.py @@ -16,5 +16,5 @@ async def test_sensor( ) -> None: """Test the Trafikverket Camera binary sensor.""" - state = hass.states.get("binary_sensor.test_location_active") + state = hass.states.get("binary_sensor.test_camera_active") assert state.state == STATE_ON diff --git a/tests/components/trafikverket_camera/test_camera.py b/tests/components/trafikverket_camera/test_camera.py index b3df7cfcdcb..182924e9f0e 100644 --- a/tests/components/trafikverket_camera/test_camera.py +++ b/tests/components/trafikverket_camera/test_camera.py @@ -26,7 +26,7 @@ async def test_camera( get_camera: CameraInfo, ) -> None: """Test the Trafikverket Camera sensor.""" - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes["description"] == "Test Camera for testing" assert state1.attributes["location"] == "Test location" @@ -44,11 +44,11 @@ async def test_camera( async_fire_time_changed(hass) await hass.async_block_till_done() - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes != {} - assert await async_get_image(hass, "camera.test_location") + assert await async_get_image(hass, "camera.test_camera") monkeypatch.setattr( get_camera, @@ -69,4 +69,4 @@ async def test_camera( await hass.async_block_till_done() with pytest.raises(HomeAssistantError): - await async_get_image(hass, "camera.test_location") + await async_get_image(hass, "camera.test_camera") diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index b53763c0ac7..305066832e5 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -14,7 +14,7 @@ from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries from homeassistant.components.trafikverket_camera.const import CONF_LOCATION, DOMAIN -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -47,10 +47,10 @@ async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test location" + assert result2["title"] == "Test Camera" assert result2["data"] == { "api_key": "1234567890", - "location": "Test location", + "id": "1234", } assert len(mock_setup_entry.mock_calls) == 1 assert result2["result"].unique_id == "trafikverket_camera-1234" @@ -87,7 +87,7 @@ async def test_form_no_location_data( assert result2["title"] == "Test Camera" assert result2["data"] == { "api_key": "1234567890", - "location": "Test Camera", + "id": "1234", } assert len(mock_setup_entry.mock_calls) == 1 assert result2["result"].unique_id == "trafikverket_camera-1234" @@ -150,10 +150,10 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: domain=DOMAIN, data={ CONF_API_KEY: "1234567890", - CONF_LOCATION: "Test location", + CONF_ID: "1234", }, unique_id="1234", - version=2, + version=3, ) entry.add_to_hass(hass) @@ -186,7 +186,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result2["reason"] == "reauth_successful" assert entry.data == { "api_key": "1234567891", - "location": "Test location", + "id": "1234", } @@ -223,10 +223,10 @@ async def test_reauth_flow_error( domain=DOMAIN, data={ CONF_API_KEY: "1234567890", - CONF_LOCATION: "Test location", + CONF_ID: "1234", }, unique_id="1234", - version=2, + version=3, ) entry.add_to_hass(hass) await hass.async_block_till_done() @@ -271,5 +271,5 @@ async def test_reauth_flow_error( assert result2["reason"] == "reauth_successful" assert entry.data == { "api_key": "1234567891", - "location": "Test location", + "id": "1234", } diff --git a/tests/components/trafikverket_camera/test_coordinator.py b/tests/components/trafikverket_camera/test_coordinator.py index 4183aa9fffa..0f79307e0b6 100644 --- a/tests/components/trafikverket_camera/test_coordinator.py +++ b/tests/components/trafikverket_camera/test_coordinator.py @@ -40,9 +40,9 @@ async def test_coordinator( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -54,7 +54,7 @@ async def test_coordinator( await hass.async_block_till_done() mock_data.assert_called_once() - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" @@ -101,9 +101,9 @@ async def test_coordinator_failed_update( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -115,7 +115,7 @@ async def test_coordinator_failed_update( await hass.async_block_till_done() mock_data.assert_called_once() - state = hass.states.get("camera.test_location") + state = hass.states.get("camera.test_camera") assert state is None assert entry.state == entry_state @@ -135,7 +135,7 @@ async def test_coordinator_failed_get_image( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", title="Test location", ) @@ -149,6 +149,6 @@ async def test_coordinator_failed_get_image( await hass.async_block_till_done() mock_data.assert_called_once() - state = hass.states.get("camera.test_location") + state = hass.states.get("camera.test_camera") assert state is None assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY diff --git a/tests/components/trafikverket_camera/test_init.py b/tests/components/trafikverket_camera/test_init.py index 83a3fc1486a..e10c6c16e33 100644 --- a/tests/components/trafikverket_camera/test_init.py +++ b/tests/components/trafikverket_camera/test_init.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime from unittest.mock import patch +import pytest from pytrafikverket.exceptions import UnknownError from pytrafikverket.trafikverket_camera import CameraInfo @@ -14,7 +15,7 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import ENTRY_CONFIG +from . import ENTRY_CONFIG, ENTRY_CONFIG_OLD_CONFIG from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -35,9 +36,9 @@ async def test_setup_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -67,9 +68,9 @@ async def test_unload_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -99,7 +100,7 @@ async def test_migrate_entry( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", unique_id="trafikverket_camera-Test location", title="Test location", @@ -114,15 +115,31 @@ async def test_migrate_entry( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.LOADED - assert entry.version == 2 + assert entry.version == 3 assert entry.unique_id == "trafikverket_camera-1234" - assert len(mock_tvt_camera.mock_calls) == 2 + assert entry.data == ENTRY_CONFIG + assert len(mock_tvt_camera.mock_calls) == 3 +@pytest.mark.parametrize( + ("version", "unique_id"), + [ + ( + 1, + "trafikverket_camera-Test location", + ), + ( + 2, + "trafikverket_camera-1234", + ), + ], +) async def test_migrate_entry_fails_with_error( hass: HomeAssistant, get_camera: CameraInfo, aioclient_mock: AiohttpClientMocker, + version: int, + unique_id: str, ) -> None: """Test migrate entry fails with api error.""" aioclient_mock.get( @@ -132,9 +149,10 @@ async def test_migrate_entry_fails_with_error( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", - unique_id="trafikverket_camera-Test location", + version=version, + unique_id=unique_id, title="Test location", ) entry.add_to_hass(hass) @@ -147,14 +165,29 @@ async def test_migrate_entry_fails_with_error( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR - assert entry.version == 1 - assert entry.unique_id == "trafikverket_camera-Test location" + assert entry.version == version + assert entry.unique_id == unique_id assert len(mock_tvt_camera.mock_calls) == 1 +@pytest.mark.parametrize( + ("version", "unique_id"), + [ + ( + 1, + "trafikverket_camera-Test location", + ), + ( + 2, + "trafikverket_camera-1234", + ), + ], +) async def test_migrate_entry_fails_no_id( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + version: int, + unique_id: str, ) -> None: """Test migrate entry fails, camera returns no id.""" aioclient_mock.get( @@ -164,9 +197,10 @@ async def test_migrate_entry_fails_no_id( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", - unique_id="trafikverket_camera-Test location", + version=version, + unique_id=unique_id, title="Test location", ) entry.add_to_hass(hass) @@ -195,8 +229,8 @@ async def test_migrate_entry_fails_no_id( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR - assert entry.version == 1 - assert entry.unique_id == "trafikverket_camera-Test location" + assert entry.version == version + assert entry.unique_id == unique_id assert len(mock_tvt_camera.mock_calls) == 1 @@ -214,7 +248,7 @@ async def test_no_migration_needed( domain=DOMAIN, source=SOURCE_USER, data=ENTRY_CONFIG, - version=2, + version=3, entry_id="1234", unique_id="trafikverket_camera-1234", title="Test location", diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py index b9add7ae483..777c6ea26b3 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -24,7 +24,7 @@ async def test_exclude_attributes( get_camera: CameraInfo, ) -> None: """Test camera has description and location excluded from recording.""" - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes["description"] == "Test Camera for testing" assert state1.attributes["location"] == "Test location" @@ -39,10 +39,10 @@ async def test_exclude_attributes( hass.states.async_entity_ids(), ) assert len(states) == 8 - assert states.get("camera.test_location") + assert states.get("camera.test_camera") for entity_states in states.values(): for state in entity_states: - if state.entity_id == "camera.test_location": + if state.entity_id == "camera.test_camera": assert "location" not in state.attributes assert "description" not in state.attributes assert "type" in state.attributes diff --git a/tests/components/trafikverket_camera/test_sensor.py b/tests/components/trafikverket_camera/test_sensor.py index 581fed1d289..c1c98aed797 100644 --- a/tests/components/trafikverket_camera/test_sensor.py +++ b/tests/components/trafikverket_camera/test_sensor.py @@ -15,15 +15,15 @@ async def test_sensor( ) -> None: """Test the Trafikverket Camera sensor.""" - state = hass.states.get("sensor.test_location_direction") + state = hass.states.get("sensor.test_camera_direction") assert state.state == "180" - state = hass.states.get("sensor.test_location_modified") + state = hass.states.get("sensor.test_camera_modified") assert state.state == "2022-04-04T04:04:04+00:00" - state = hass.states.get("sensor.test_location_photo_time") + state = hass.states.get("sensor.test_camera_photo_time") assert state.state == "2022-04-04T04:04:04+00:00" - state = hass.states.get("sensor.test_location_photo_url") + state = hass.states.get("sensor.test_camera_photo_url") assert state.state == "https://www.testurl.com/test_photo.jpg" - state = hass.states.get("sensor.test_location_status") + state = hass.states.get("sensor.test_camera_status") assert state.state == "Running" - state = hass.states.get("sensor.test_location_camera_type") + state = hass.states.get("sensor.test_camera_camera_type") assert state.state == "Road" From 49381cefa3d2e384bdbbb73f33cffadf30c53204 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Nov 2023 13:37:23 +0100 Subject: [PATCH 230/643] Update frontend to 20231129.0 (#104710) --- 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 469deab23e1..6a6c557c7b7 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==20231030.2"] + "requirements": ["home-assistant-frontend==20231129.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9ea82cb3e75..4aa2a4ea013 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231030.2 +home-assistant-frontend==20231129.0 home-assistant-intents==2023.11.17 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index fe298d256da..f76a27f0d8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231030.2 +home-assistant-frontend==20231129.0 # homeassistant.components.conversation home-assistant-intents==2023.11.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c8a9e7d10c..64cd02ec6c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231030.2 +home-assistant-frontend==20231129.0 # homeassistant.components.conversation home-assistant-intents==2023.11.17 From 953a212dd697c5b916a335869bed8b36be466b92 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 13:56:51 +0100 Subject: [PATCH 231/643] Use ServiceValidationError for invalid fan preset_mode and move check to fan entity component (#104560) * Use ServiceValidationError for fan preset_mode * Use _valid_preset_mode_or_raise to raise * Move preset_mode validation to entity component * Fix bond fan and comments * Fixes baf, fjaraskupan and template * More integration adjustments * Add custom components mock and test code * Make NotValidPresetModeError subclass * Update homeassistant/components/fan/strings.json Co-authored-by: Martin Hjelmare * Keep bond has_action validation * Move demo test asserts outside context block * Follow up comment * Update homeassistant/components/fan/strings.json Co-authored-by: G Johansson * Fix demo tests * Remove pylint disable * Remove unreachable code * Update homeassistant/components/fan/__init__.py Co-authored-by: G Johansson * Use NotValidPresetModeError, Final methods * Address comments * Correct docst * Follow up comments * Update homeassistant/components/fan/__init__.py Co-authored-by: Erik Montnemery --------- Co-authored-by: Martin Hjelmare Co-authored-by: G Johansson Co-authored-by: Erik Montnemery --- homeassistant/components/baf/fan.py | 2 - homeassistant/components/bond/fan.py | 4 -- homeassistant/components/demo/fan.py | 13 +--- homeassistant/components/fan/__init__.py | 49 ++++++++++++-- homeassistant/components/fan/strings.json | 5 ++ homeassistant/components/fjaraskupan/fan.py | 8 +-- homeassistant/components/mqtt/fan.py | 2 - homeassistant/components/template/fan.py | 9 --- homeassistant/components/tradfri/fan.py | 3 +- homeassistant/components/vallox/fan.py | 12 +--- homeassistant/components/xiaomi_miio/fan.py | 22 ------- homeassistant/components/zha/fan.py | 6 -- homeassistant/components/zwave_js/fan.py | 6 -- tests/components/bond/test_fan.py | 9 ++- tests/components/demo/test_fan.py | 24 +++++-- tests/components/fan/test_init.py | 65 ++++++++++++++++++- tests/components/mqtt/test_fan.py | 40 +++++++----- tests/components/template/test_fan.py | 16 +++-- tests/components/vallox/test_fan.py | 4 +- tests/components/zha/test_fan.py | 9 ++- tests/components/zwave_js/test_fan.py | 6 +- .../custom_components/test/fan.py | 64 ++++++++++++++++++ 22 files changed, 260 insertions(+), 118 deletions(-) create mode 100644 tests/testing_config/custom_components/test/fan.py diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index 059603fc589..e2d1c5fcb3a 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -93,8 +93,6 @@ class BAFFan(BAFEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode != PRESET_MODE_AUTO: - raise ValueError(f"Invalid preset mode: {preset_mode}") self._device.fan_mode = OffOnAuto.AUTO async def async_set_direction(self, direction: str) -> None: diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index bc6235cb219..3cb81ba40b4 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -199,10 +199,6 @@ class BondFan(BondEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode != PRESET_MODE_BREEZE or not self._device.has_action( - Action.BREEZE_ON - ): - raise ValueError(f"Invalid preset mode: {preset_mode}") await self._hub.bond.action(self._device.device_id, Action(Action.BREEZE_ON)) async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 211389a5466..73cae4a64b1 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -161,12 +161,9 @@ class DemoPercentageFan(BaseDemoFan, FanEntity): def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if self.preset_modes and preset_mode in self.preset_modes: - self._preset_mode = preset_mode - self._percentage = None - self.schedule_update_ha_state() - else: - raise ValueError(f"Invalid preset mode: {preset_mode}") + self._preset_mode = preset_mode + self._percentage = None + self.schedule_update_ha_state() def turn_on( self, @@ -230,10 +227,6 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if self.preset_modes is None or preset_mode not in self.preset_modes: - raise ValueError( - f"{preset_mode} is not a valid preset_mode: {self.preset_modes}" - ) self._preset_mode = preset_mode self._percentage = None self.async_write_ha_state() diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index a149909e029..21ffca35962 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -18,7 +18,8 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -77,8 +78,19 @@ ATTR_PRESET_MODES = "preset_modes" # mypy: disallow-any-generics -class NotValidPresetModeError(ValueError): - """Exception class when the preset_mode in not in the preset_modes list.""" +class NotValidPresetModeError(ServiceValidationError): + """Raised when the preset_mode is not in the preset_modes list.""" + + def __init__( + self, *args: object, translation_placeholders: dict[str, str] | None = None + ) -> None: + """Initialize the exception.""" + super().__init__( + *args, + translation_domain=DOMAIN, + translation_key="not_valid_preset_mode", + translation_placeholders=translation_placeholders, + ) @bind_hass @@ -107,7 +119,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ), vol.Optional(ATTR_PRESET_MODE): cv.string, }, - "async_turn_on", + "async_handle_turn_on_service", ) component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") @@ -156,7 +168,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_PRESET_MODE, {vol.Required(ATTR_PRESET_MODE): cv.string}, - "async_set_preset_mode", + "async_handle_set_preset_mode_service", [FanEntityFeature.SET_SPEED, FanEntityFeature.PRESET_MODE], ) @@ -237,17 +249,30 @@ class FanEntity(ToggleEntity): """Set new preset mode.""" raise NotImplementedError() + @final + async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_preset_mode_or_raise(preset_mode) + await self.async_set_preset_mode(preset_mode) + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) + @final + @callback def _valid_preset_mode_or_raise(self, preset_mode: str) -> None: """Raise NotValidPresetModeError on invalid preset_mode.""" preset_modes = self.preset_modes if not preset_modes or preset_mode not in preset_modes: + preset_modes_str: str = ", ".join(preset_modes or []) raise NotValidPresetModeError( f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {preset_modes}" + f" {preset_modes}", + translation_placeholders={ + "preset_mode": preset_mode, + "preset_modes": preset_modes_str, + }, ) def set_direction(self, direction: str) -> None: @@ -267,6 +292,18 @@ class FanEntity(ToggleEntity): """Turn on the fan.""" raise NotImplementedError() + @final + async def async_handle_turn_on_service( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Validate and turn on the fan.""" + if preset_mode is not None: + self._valid_preset_mode_or_raise(preset_mode) + await self.async_turn_on(percentage, preset_mode, **kwargs) + async def async_turn_on( self, percentage: int | None = None, diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index 674dcc2b92e..aab714d3e07 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -144,5 +144,10 @@ "reverse": "Reverse" } } + }, + "exceptions": { + "not_valid_preset_mode": { + "message": "Preset mode {preset_mode} is not valid, valid preset modes are: {preset_modes}." + } } } diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index 142694a6bfb..ee989bb2ee0 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -131,11 +131,9 @@ class Fan(CoordinatorEntity[FjaraskupanCoordinator], FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if command := PRESET_TO_COMMAND.get(preset_mode): - async with self.coordinator.async_connect_and_update() as device: - await device.send_command(command) - else: - raise UnsupportedPreset(f"The preset {preset_mode} is unsupported") + command = PRESET_TO_COMMAND[preset_mode] + async with self.coordinator.async_connect_and_update() as device: + await device.send_command(command) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 0e9e7d708e9..e3dcf66c8b1 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -553,8 +553,6 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ - self._valid_preset_mode_or_raise(preset_mode) - mqtt_payload = self._command_templates[ATTR_PRESET_MODE](preset_mode) await self.async_publish( diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index d39fa56775a..8aeede42552 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -282,15 +282,6 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset_mode of the fan.""" - if self.preset_modes and preset_mode not in self.preset_modes: - _LOGGER.error( - "Received invalid preset_mode: %s for entity %s. Expected: %s", - preset_mode, - self.entity_id, - self.preset_modes, - ) - return - self._preset_mode = preset_mode if self._set_preset_mode_script: diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index c41b24a2647..5c0f05004ba 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -119,8 +119,7 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): if not self._device_control: return - if not preset_mode == ATTR_AUTO: - raise ValueError("Preset must be 'Auto'.") + # Preset must be 'Auto' await self._api(self._device_control.turn_on_auto_mode()) diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 2f420096c74..e58c3ebd88d 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -11,11 +11,7 @@ from vallox_websocket_api import ( ValloxInvalidInputException, ) -from homeassistant.components.fan import ( - FanEntity, - FanEntityFeature, - NotValidPresetModeError, -) +from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -200,12 +196,6 @@ class ValloxFanEntity(ValloxEntity, FanEntity): Returns true if the mode has been changed, false otherwise. """ - try: - self._valid_preset_mode_or_raise(preset_mode) - - except NotValidPresetModeError as err: - raise ValueError(f"Not valid preset mode: {preset_mode}") from err - if preset_mode == self.preset_mode: return False diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index a3bb28e7a8b..9be019ed724 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -530,9 +530,6 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): This method is a coroutine. """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -623,9 +620,6 @@ class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -721,9 +715,6 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): This method is a coroutine. """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -809,9 +800,6 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan. This method is a coroutine.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -958,10 +946,6 @@ class XiaomiFan(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return - if preset_mode == ATTR_MODE_NATURE: await self._try_command( "Setting natural fan speed percentage of the miio device failed.", @@ -1034,9 +1018,6 @@ class XiaomiFanP5(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -1093,9 +1074,6 @@ class XiaomiFanMiot(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 05bf3469c7b..c6b9a104885 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -13,7 +13,6 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODE, FanEntity, FanEntityFeature, - NotValidPresetModeError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, Platform @@ -131,11 +130,6 @@ class BaseFan(FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode for the fan.""" - if preset_mode not in self.preset_modes: - raise NotValidPresetModeError( - f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {self.preset_modes}" - ) await self._async_set_fan_mode(self.preset_name_to_mode[preset_mode]) @abstractmethod diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index d0630649765..d4247b65c8b 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -18,7 +18,6 @@ from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, FanEntity, FanEntityFeature, - NotValidPresetModeError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -181,11 +180,6 @@ class ValueMappingZwaveFan(ZwaveFan): await self._async_set_value(self._target_value, zwave_value) return - raise NotValidPresetModeError( - f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {self.preset_modes}" - ) - @property def available(self) -> bool: """Return whether the entity is available.""" diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index db1c0fc787d..e202433c8d6 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -26,6 +26,7 @@ from homeassistant.components.fan import ( SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, FanEntityFeature, + NotValidPresetModeError, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -251,10 +252,14 @@ async def test_turn_on_fan_preset_mode_not_supported(hass: HomeAssistant) -> Non props={"max_speed": 6}, ) - with patch_bond_action(), patch_bond_device_state(), pytest.raises(ValueError): + with patch_bond_action(), patch_bond_device_state(), pytest.raises( + NotValidPresetModeError + ): await turn_fan_on(hass, "fan.name_1", preset_mode=PRESET_MODE_BREEZE) - with patch_bond_action(), patch_bond_device_state(), pytest.raises(ValueError): + with patch_bond_action(), patch_bond_device_state(), pytest.raises( + NotValidPresetModeError + ): await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index 58a8c99ea3c..a3f607aee76 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -182,7 +182,7 @@ async def test_turn_on_with_preset_mode_only( assert state.state == STATE_OFF assert state.attributes[fan.ATTR_PRESET_MODE] is None - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -190,6 +190,12 @@ async def test_turn_on_with_preset_mode_only( blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" + assert exc.value.translation_placeholders == { + "preset_mode": "invalid", + "preset_modes": "auto, smart, sleep, on", + } state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF @@ -250,7 +256,7 @@ async def test_turn_on_with_preset_mode_and_speed( assert state.attributes[fan.ATTR_PERCENTAGE] == 0 assert state.attributes[fan.ATTR_PRESET_MODE] is None - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -258,6 +264,12 @@ async def test_turn_on_with_preset_mode_and_speed( blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" + assert exc.value.translation_placeholders == { + "preset_mode": "invalid", + "preset_modes": "auto, smart, sleep, on", + } state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF @@ -343,7 +355,7 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_PRESET_MODE, @@ -351,8 +363,10 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -360,6 +374,8 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 8338afc9c68..ec421141768 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -1,8 +1,19 @@ """Tests for fan platforms.""" import pytest -from homeassistant.components.fan import FanEntity +from homeassistant.components.fan import ( + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DOMAIN, + SERVICE_SET_PRESET_MODE, + FanEntity, + NotValidPresetModeError, +) from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.testing_config.custom_components.test.fan import MockFan class BaseFan(FanEntity): @@ -82,3 +93,55 @@ def test_fanentity_attributes(attribute_name, attribute_value) -> None: fan = BaseFan() setattr(fan, f"_attr_{attribute_name}", attribute_value) assert getattr(fan, attribute_name) == attribute_value + + +async def test_preset_mode_validation( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test preset mode validation.""" + + await hass.async_block_till_done() + + platform = getattr(hass.components, "test.fan") + platform.init(empty=False) + + assert await async_setup_component(hass, "fan", {"fan": {"platform": "test"}}) + await hass.async_block_till_done() + + test_fan: MockFan = platform.ENTITIES["support_preset_mode"] + await hass.async_block_till_done() + + state = hass.states.get("fan.support_fan_with_preset_mode_support") + assert state.attributes.get(ATTR_PRESET_MODES) == ["auto", "eco"] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "fan.support_fan_with_preset_mode_support", + "preset_mode": "eco", + }, + blocking=True, + ) + + state = hass.states.get("fan.support_fan_with_preset_mode_support") + assert state.attributes.get(ATTR_PRESET_MODE) == "eco" + + with pytest.raises(NotValidPresetModeError) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "fan.support_fan_with_preset_mode_support", + "preset_mode": "invalid", + }, + blocking=True, + ) + assert exc.value.translation_key == "not_valid_preset_mode" + + with pytest.raises(NotValidPresetModeError) as exc: + await test_fan._valid_preset_mode_or_raise("invalid") + assert exc.value.translation_key == "not_valid_preset_mode" diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 21d3bcce3a9..e7c4eba54e2 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -705,8 +705,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -916,11 +917,13 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "auto") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -976,8 +979,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="freaking-high") + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize( @@ -1078,11 +1082,13 @@ async def test_sending_mqtt_command_templates_( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1140,8 +1146,9 @@ async def test_sending_mqtt_command_templates_( assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="low") + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize( @@ -1176,8 +1183,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1276,11 +1284,10 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="auto") - assert mqtt_mock.async_publish.call_count == 1 - # We can turn on, but the invalid preset mode will raise - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + assert exc.value.translation_key == "not_valid_preset_mode" + assert mqtt_mock.async_publish.call_count == 0 mqtt_mock.async_publish.reset_mock() await common.async_turn_on(hass, "fan.test", preset_mode="whoosh") @@ -1428,11 +1435,13 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( with pytest.raises(MultipleInvalid): await common.async_set_percentage(hass, "fan.test", 101) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1452,8 +1461,9 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "freaking-high") + assert exc.value.translation_key == "not_valid_preset_mode" mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index f9b0bddddcf..ccdafebd8bb 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -12,6 +12,7 @@ from homeassistant.components.fan import ( DIRECTION_REVERSE, DOMAIN, FanEntityFeature, + NotValidPresetModeError, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -489,7 +490,11 @@ async def test_preset_modes(hass: HomeAssistant, calls) -> None: ("smart", "smart", 3), ("invalid", "smart", 3), ]: - await common.async_set_preset_mode(hass, _TEST_FAN, extra) + if extra != state: + with pytest.raises(NotValidPresetModeError): + await common.async_set_preset_mode(hass, _TEST_FAN, extra) + else: + await common.async_set_preset_mode(hass, _TEST_FAN, extra) assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == state assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_preset_mode" @@ -550,6 +555,7 @@ async def test_no_value_template(hass: HomeAssistant, calls) -> None: with assert_setup_component(1, "fan"): test_fan_config = { "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "preset_modes": ["auto"], "percentage_template": "{{ states('input_number.percentage') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", @@ -625,18 +631,18 @@ async def test_no_value_template(hass: HomeAssistant, calls) -> None: await hass.async_block_till_done() await common.async_turn_on(hass, _TEST_FAN) - _verify(hass, STATE_ON, 0, None, None, None) + _verify(hass, STATE_ON, 0, None, None, "auto") await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, 0, None, None, None) + _verify(hass, STATE_OFF, 0, None, None, "auto") percent = 100 await common.async_set_percentage(hass, _TEST_FAN, percent) assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == percent - _verify(hass, STATE_ON, percent, None, None, None) + _verify(hass, STATE_ON, percent, None, None, "auto") await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, percent, None, None, None) + _verify(hass, STATE_OFF, percent, None, None, "auto") preset = "auto" await common.async_set_preset_mode(hass, _TEST_FAN, preset) diff --git a/tests/components/vallox/test_fan.py b/tests/components/vallox/test_fan.py index eb60a3d025d..12b24f46aba 100644 --- a/tests/components/vallox/test_fan.py +++ b/tests/components/vallox/test_fan.py @@ -10,6 +10,7 @@ from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, + NotValidPresetModeError, ) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant @@ -179,7 +180,7 @@ async def test_set_invalid_preset_mode( """Test set preset mode.""" await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - with pytest.raises(ValueError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -189,6 +190,7 @@ async def test_set_invalid_preset_mode( }, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" async def test_set_preset_mode_exception( diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 737604482d8..7d45960d576 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -222,10 +222,11 @@ async def test_fan( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode( hass, entity_id, preset_mode="invalid does not exist" ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA @@ -624,10 +625,11 @@ async def test_fan_ikea( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode( hass, entity_id, preset_mode="invalid does not exist" ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA @@ -813,8 +815,9 @@ async def test_fan_kof( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 92141eec3ff..c26a5366d37 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -536,13 +536,14 @@ async def test_inovelli_lzw36( assert args["value"] == 1 client.async_send_command.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( "fan", "turn_on", {"entity_id": entity_id, "preset_mode": "wheeze"}, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(client.async_send_command.call_args_list) == 0 @@ -675,13 +676,14 @@ async def test_thermostat_fan( client.async_send_command.reset_mock() # Test setting unknown preset mode - with pytest.raises(ValueError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "Turbo"}, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" client.async_send_command.reset_mock() diff --git a/tests/testing_config/custom_components/test/fan.py b/tests/testing_config/custom_components/test/fan.py new file mode 100644 index 00000000000..133f372f4fa --- /dev/null +++ b/tests/testing_config/custom_components/test/fan.py @@ -0,0 +1,64 @@ +"""Provide a mock fan platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from tests.common import MockEntity + +ENTITIES = {} + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + {} + if empty + else { + "support_preset_mode": MockFan( + name="Support fan with preset_mode support", + supported_features=FanEntityFeature.PRESET_MODE, + unique_id="unique_support_preset_mode", + preset_modes=["auto", "eco"], + ) + } + ) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities_callback: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +): + """Return mock entities.""" + async_add_entities_callback(list(ENTITIES.values())) + + +class MockFan(MockEntity, FanEntity): + """Mock Fan class.""" + + @property + def preset_mode(self) -> str | None: + """Return preset mode.""" + return self._handle("preset_mode") + + @property + def preset_modes(self) -> list[str] | None: + """Return preset mode.""" + return self._handle("preset_modes") + + @property + def supported_features(self): + """Return the class of this fan.""" + return self._handle("supported_features") + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode.""" + self._attr_preset_mode = preset_mode + await self.async_update_ha_state() From 0a1396820929cfc1ab445365ffdce2228fadf265 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 29 Nov 2023 14:17:15 +0100 Subject: [PATCH 232/643] Improve devialet coordinator typing (#104707) --- homeassistant/components/devialet/coordinator.py | 4 ++-- homeassistant/components/devialet/media_player.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/devialet/coordinator.py b/homeassistant/components/devialet/coordinator.py index f0ee47150cc..9e1eada7183 100644 --- a/homeassistant/components/devialet/coordinator.py +++ b/homeassistant/components/devialet/coordinator.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -class DevialetCoordinator(DataUpdateCoordinator): +class DevialetCoordinator(DataUpdateCoordinator[None]): """Devialet update coordinator.""" def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None: @@ -27,6 +27,6 @@ class DevialetCoordinator(DataUpdateCoordinator): ) self.client = client - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch data from API endpoint.""" await self.client.async_update() diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py index 75fc420fa87..a79a82e6f60 100644 --- a/homeassistant/components/devialet/media_player.py +++ b/homeassistant/components/devialet/media_player.py @@ -46,13 +46,15 @@ async def async_setup_entry( async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)]) -class DevialetMediaPlayerEntity(CoordinatorEntity, MediaPlayerEntity): +class DevialetMediaPlayerEntity( + CoordinatorEntity[DevialetCoordinator], MediaPlayerEntity +): """Devialet media player.""" _attr_has_entity_name = True _attr_name = None - def __init__(self, coordinator, entry: ConfigEntry) -> None: + def __init__(self, coordinator: DevialetCoordinator, entry: ConfigEntry) -> None: """Initialize the Devialet device.""" self.coordinator = coordinator super().__init__(coordinator) From 09d7679818bffeba4a999380722e281315afe3cf Mon Sep 17 00:00:00 2001 From: stegm Date: Wed, 29 Nov 2023 14:24:09 +0100 Subject: [PATCH 233/643] Add new sensors of Kostal Plenticore integration (#103802) --- .../components/kostal_plenticore/helper.py | 24 ++++-- .../kostal_plenticore/manifest.json | 2 +- .../components/kostal_plenticore/sensor.py | 79 +++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/kostal_plenticore/conftest.py | 24 +++--- .../kostal_plenticore/test_config_flow.py | 28 ++++++- .../kostal_plenticore/test_diagnostics.py | 22 +++--- .../kostal_plenticore/test_helper.py | 34 ++++++-- .../kostal_plenticore/test_number.py | 68 +++++++--------- .../kostal_plenticore/test_select.py | 20 ++++- 11 files changed, 221 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index 1c495ac9db9..adb1bfb6f09 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -3,13 +3,18 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging from typing import Any, TypeVar, cast from aiohttp.client_exceptions import ClientError -from pykoplenti import ApiClient, ApiException, AuthenticationException +from pykoplenti import ( + ApiClient, + ApiException, + AuthenticationException, + ExtendedApiClient, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -51,7 +56,9 @@ class Plenticore: async def async_setup(self) -> bool: """Set up Plenticore API client.""" - self._client = ApiClient(async_get_clientsession(self.hass), host=self.host) + self._client = ExtendedApiClient( + async_get_clientsession(self.hass), host=self.host + ) try: await self._client.login(self.config_entry.data[CONF_PASSWORD]) except AuthenticationException as err: @@ -124,7 +131,7 @@ class DataUpdateCoordinatorMixin: async def async_read_data( self, module_id: str, data_id: str - ) -> dict[str, dict[str, str]] | None: + ) -> Mapping[str, Mapping[str, str]] | None: """Read data from Plenticore.""" if (client := self._plenticore.client) is None: return None @@ -190,7 +197,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): class ProcessDataUpdateCoordinator( - PlenticoreUpdateCoordinator[dict[str, dict[str, str]]] + PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]] ): """Implementation of PlenticoreUpdateCoordinator for process data.""" @@ -206,18 +213,19 @@ class ProcessDataUpdateCoordinator( return { module_id: { process_data.id: process_data.value - for process_data in fetched_data[module_id] + for process_data in fetched_data[module_id].values() } for module_id in fetched_data } class SettingDataUpdateCoordinator( - PlenticoreUpdateCoordinator[dict[str, dict[str, str]]], DataUpdateCoordinatorMixin + PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]], + DataUpdateCoordinatorMixin, ): """Implementation of PlenticoreUpdateCoordinator for settings data.""" - async def _async_update_data(self) -> dict[str, dict[str, str]]: + async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]: client = self._plenticore.client if not self._fetch or client is None: diff --git a/homeassistant/components/kostal_plenticore/manifest.json b/homeassistant/components/kostal_plenticore/manifest.json index 95f4a194977..d65368e7ee4 100644 --- a/homeassistant/components/kostal_plenticore/manifest.json +++ b/homeassistant/components/kostal_plenticore/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/kostal_plenticore", "iot_class": "local_polling", "loggers": ["kostal"], - "requirements": ["pykoplenti==1.0.0"] + "requirements": ["pykoplenti==1.2.2"] } diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index f7bad638df4..ce18867511d 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -649,6 +649,39 @@ SENSOR_PROCESS_DATA = [ state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Day", + name="Battery Discharge Day", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Month", + name="Battery Discharge Month", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Year", + name="Battery Discharge Year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Total", + name="Battery Discharge Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), PlenticoreSensorEntityDescription( module_id="scb:statistic:EnergyFlow", key="Statistic:EnergyDischargeGrid:Day", @@ -682,6 +715,52 @@ SENSOR_PROCESS_DATA = [ state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="pv_P", + name="Sum power of all PV DC inputs", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=True, + state_class=SensorStateClass.MEASUREMENT, + formatter="format_round", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Total", + name="Energy to Grid Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Year", + name="Energy to Grid Year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Month", + name="Energy to Grid Month", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Day", + name="Energy to Grid Day", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), ] diff --git a/requirements_all.txt b/requirements_all.txt index f76a27f0d8e..2f88508ce0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1835,7 +1835,7 @@ pykmtronic==0.3.0 pykodi==0.2.7 # homeassistant.components.kostal_plenticore -pykoplenti==1.0.0 +pykoplenti==1.2.2 # homeassistant.components.kraken pykrakenapi==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64cd02ec6c4..6fb4dda4222 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1388,7 +1388,7 @@ pykmtronic==0.3.0 pykodi==0.2.7 # homeassistant.components.kostal_plenticore -pykoplenti==1.0.0 +pykoplenti==1.2.2 # homeassistant.components.kraken pykrakenapi==0.1.8 diff --git a/tests/components/kostal_plenticore/conftest.py b/tests/components/kostal_plenticore/conftest.py index 814a46f4a25..a83d9fd5e17 100644 --- a/tests/components/kostal_plenticore/conftest.py +++ b/tests/components/kostal_plenticore/conftest.py @@ -49,24 +49,20 @@ def mock_plenticore() -> Generator[Plenticore, None, None]: plenticore.client.get_version = AsyncMock() plenticore.client.get_version.return_value = VersionData( - { - "api_version": "0.2.0", - "hostname": "scb", - "name": "PUCK RESTful API", - "sw_version": "01.16.05025", - } + api_version="0.2.0", + hostname="scb", + name="PUCK RESTful API", + sw_version="01.16.05025", ) plenticore.client.get_me = AsyncMock() plenticore.client.get_me.return_value = MeData( - { - "locked": False, - "active": True, - "authenticated": True, - "permissions": [], - "anonymous": False, - "role": "USER", - } + locked=False, + active=True, + authenticated=True, + permissions=[], + anonymous=False, + role="USER", ) plenticore.client.get_process_data = AsyncMock() diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index 41facfe9c26..8bfe227bfdf 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -54,7 +54,19 @@ async def test_form_g1( # mock of the context manager instance mock_apiclient.login = AsyncMock() mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ), + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" @@ -108,7 +120,19 @@ async def test_form_g2( # mock of the context manager instance mock_apiclient.login = AsyncMock() mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Network:Hostname", + type="string", + ), + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index d6a57648400..87c8c0e26a8 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -26,15 +26,13 @@ async def test_entry_diagnostics( mock_plenticore.client.get_settings.return_value = { "devices:local": [ SettingsData( - { - "id": "Battery:MinSoc", - "unit": "%", - "default": "None", - "min": 5, - "max": 100, - "type": "byte", - "access": "readwrite", - } + min="5", + max="100", + default=None, + access="readwrite", + unit="%", + id="Battery:MinSoc", + type="byte", ) ] } @@ -56,12 +54,12 @@ async def test_entry_diagnostics( "disabled_by": None, }, "client": { - "version": "Version(api_version=0.2.0, hostname=scb, name=PUCK RESTful API, sw_version=01.16.05025)", - "me": "Me(locked=False, active=True, authenticated=True, permissions=[], anonymous=False, role=USER)", + "version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'", + "me": "is_locked=False is_active=True is_authenticated=True permissions=[] is_anonymous=False role='USER'", "available_process_data": {"devices:local": ["HomeGrid_P", "HomePv_P"]}, "available_settings_data": { "devices:local": [ - "SettingsData(id=Battery:MinSoc, unit=%, default=None, min=5, max=100,type=byte, access=readwrite)" + "min='5' max='100' default=None access='readwrite' unit='%' id='Battery:MinSoc' type='byte'" ] }, }, diff --git a/tests/components/kostal_plenticore/test_helper.py b/tests/components/kostal_plenticore/test_helper.py index 61df222fd9e..93550405897 100644 --- a/tests/components/kostal_plenticore/test_helper.py +++ b/tests/components/kostal_plenticore/test_helper.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from pykoplenti import ApiClient, SettingsData +from pykoplenti import ApiClient, ExtendedApiClient, SettingsData import pytest from homeassistant.components.kostal_plenticore.const import DOMAIN @@ -17,10 +17,10 @@ from tests.common import MockConfigEntry def mock_apiclient() -> Generator[ApiClient, None, None]: """Return a mocked ApiClient class.""" with patch( - "homeassistant.components.kostal_plenticore.helper.ApiClient", + "homeassistant.components.kostal_plenticore.helper.ExtendedApiClient", autospec=True, ) as mock_api_class: - apiclient = MagicMock(spec=ApiClient) + apiclient = MagicMock(spec=ExtendedApiClient) apiclient.__aenter__.return_value = apiclient apiclient.__aexit__ = AsyncMock() mock_api_class.return_value = apiclient @@ -34,7 +34,19 @@ async def test_plenticore_async_setup_g1( ) -> None: """Tests the async_setup() method of the Plenticore class for G1 models.""" mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ) + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" @@ -74,7 +86,19 @@ async def test_plenticore_async_setup_g2( ) -> None: """Tests the async_setup() method of the Plenticore class for G2 models.""" mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Network:Hostname", + type="string", + ) + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index dd5ba7127a8..fc7d9f213fe 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -23,9 +23,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture def mock_plenticore_client() -> Generator[ApiClient, None, None]: - """Return a patched ApiClient.""" + """Return a patched ExtendedApiClient.""" with patch( - "homeassistant.components.kostal_plenticore.helper.ApiClient", + "homeassistant.components.kostal_plenticore.helper.ExtendedApiClient", autospec=True, ) as plenticore_client_class: yield plenticore_client_class.return_value @@ -41,39 +41,33 @@ def mock_get_setting_values(mock_plenticore_client: ApiClient) -> list: mock_plenticore_client.get_settings.return_value = { "devices:local": [ SettingsData( - { - "default": None, - "min": 5, - "max": 100, - "access": "readwrite", - "unit": "%", - "type": "byte", - "id": "Battery:MinSoc", - } + min="5", + max="100", + default=None, + access="readwrite", + unit="%", + id="Battery:MinSoc", + type="byte", ), SettingsData( - { - "default": None, - "min": 50, - "max": 38000, - "access": "readwrite", - "unit": "W", - "type": "byte", - "id": "Battery:MinHomeComsumption", - } + min="50", + max="38000", + default=None, + access="readwrite", + unit="W", + id="Battery:MinHomeComsumption", + type="byte", ), ], "scb:network": [ SettingsData( - { - "min": "1", - "default": None, - "access": "readwrite", - "unit": None, - "id": "Hostname", - "type": "string", - "max": "63", - } + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", ) ], } @@ -129,15 +123,13 @@ async def test_setup_no_entries( mock_plenticore_client.get_settings.return_value = { "scb:network": [ SettingsData( - { - "min": "1", - "default": None, - "access": "readwrite", - "unit": None, - "id": "Hostname", - "type": "string", - "max": "63", - } + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", ) ], } diff --git a/tests/components/kostal_plenticore/test_select.py b/tests/components/kostal_plenticore/test_select.py index 682e8f72ac8..9af2589af9b 100644 --- a/tests/components/kostal_plenticore/test_select.py +++ b/tests/components/kostal_plenticore/test_select.py @@ -18,8 +18,24 @@ async def test_select_battery_charging_usage_available( mock_plenticore.client.get_settings.return_value = { "devices:local": [ - SettingsData({"id": "Battery:SmartBatteryControl:Enable"}), - SettingsData({"id": "Battery:TimeControl:Enable"}), + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:SmartBatteryControl:Enable", + type="string", + ), + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:TimeControl:Enable", + type="string", + ), ] } From e884933dbdd5088eb4bd91a4a6e8f16df6e87aca Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 29 Nov 2023 14:46:19 +0100 Subject: [PATCH 234/643] Remove rest api service call timeout (#104709) --- homeassistant/components/api/__init__.py | 13 ++++------ tests/components/api/test_init.py | 32 ++++++++++++++++++------ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 6bb3cc34050..7e4966e2b0d 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -390,17 +390,14 @@ class APIDomainServicesView(HomeAssistantView): ) try: - async with timeout(SERVICE_WAIT_TIMEOUT): - # shield the service call from cancellation on connection drop - await shield( - hass.services.async_call( - domain, service, data, blocking=True, context=context - ) + # shield the service call from cancellation on connection drop + await shield( + hass.services.async_call( + domain, service, data, blocking=True, context=context ) + ) except (vol.Invalid, ServiceNotFound) as ex: raise HTTPBadRequest() from ex - except TimeoutError: - pass finally: cancel_listen() diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index f97b55c3ede..08cb77b4559 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -1,9 +1,10 @@ """The tests for the Home Assistant API component.""" +import asyncio from http import HTTPStatus import json from unittest.mock import patch -from aiohttp import web +from aiohttp import ServerDisconnectedError, web from aiohttp.test_utils import TestClient import pytest import voluptuous as vol @@ -352,26 +353,41 @@ async def test_api_call_service_with_data( assert state["attributes"] == {"data": 1} -async def test_api_call_service_timeout( +async def test_api_call_service_client_closed( hass: HomeAssistant, mock_api_client: TestClient ) -> None: - """Test if the API does not fail on long running services.""" + """Test that services keep running if client is closed.""" test_value = [] fut = hass.loop.create_future() + service_call_started = asyncio.Event() async def listener(service_call): """Wait and return after mock_api_client.post finishes.""" + service_call_started.set() value = await fut test_value.append(value) hass.services.async_register("test_domain", "test_service", listener) - with patch("homeassistant.components.api.SERVICE_WAIT_TIMEOUT", 0): - await mock_api_client.post("/api/services/test_domain/test_service") - assert len(test_value) == 0 - fut.set_result(1) - await hass.async_block_till_done() + api_task = hass.async_create_task( + mock_api_client.post("/api/services/test_domain/test_service") + ) + + await service_call_started.wait() + + assert len(test_value) == 0 + + await mock_api_client.close() + + assert len(test_value) == 0 + assert api_task.done() + + with pytest.raises(ServerDisconnectedError): + await api_task + + fut.set_result(1) + await hass.async_block_till_done() assert len(test_value) == 1 assert test_value[0] == 1 From 8f2e69fdb7d586f5d1ef873a04c0945e8a4e85c5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 15:18:25 +0100 Subject: [PATCH 235/643] Revert "Update stookwijzer api to atlas leefomgeving (#103323)" (#104705) --- homeassistant/components/stookwijzer/const.py | 6 +++--- homeassistant/components/stookwijzer/diagnostics.py | 9 ++++----- homeassistant/components/stookwijzer/manifest.json | 2 +- homeassistant/components/stookwijzer/sensor.py | 6 +++--- homeassistant/components/stookwijzer/strings.json | 6 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 16 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index d1ac46148a7..1a125da6a6b 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -10,6 +10,6 @@ LOGGER = logging.getLogger(__package__) class StookwijzerState(StrEnum): """Stookwijzer states for sensor entity.""" - CODE_YELLOW = "code_yellow" - CODE_ORANGE = "code_orange" - CODE_RED = "code_red" + BLUE = "blauw" + ORANGE = "oranje" + RED = "rood" diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index 85996bb6394..e29606cb191 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -24,9 +24,8 @@ async def async_get_config_entry_diagnostics( return { "state": client.state, "last_updated": last_updated, - "alert": client.alert, - "air_quality_index": client.lki, - "windspeed_bft": client.windspeed_bft, - "windspeed_ms": client.windspeed_ms, - "forecast": client.forecast, + "lqi": client.lqi, + "windspeed": client.windspeed, + "weather": client.weather, + "concentrations": client.concentrations, } diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 91504ef923f..dbf902b1e1e 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.4.2"] + "requirements": ["stookwijzer==1.3.0"] } diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index e8d03499a8e..312f8bdd02d 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry( class StookwijzerSensor(SensorEntity): """Defines a Stookwijzer binary sensor.""" - _attr_attribution = "Data provided by atlasleefomgeving.nl" + _attr_attribution = "Data provided by stookwijzer.nu" _attr_device_class = SensorDeviceClass.ENUM _attr_has_entity_name = True _attr_name = None @@ -43,9 +43,9 @@ class StookwijzerSensor(SensorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{entry.entry_id}")}, name="Stookwijzer", - manufacturer="Atlas Leefomgeving", + manufacturer="stookwijzer.nu", entry_type=DeviceEntryType.SERVICE, - configuration_url="https://www.atlasleefomgeving.nl/stookwijzer", + configuration_url="https://www.stookwijzer.nu", ) def update(self) -> None: diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index 62006f878c8..549673165ec 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -13,9 +13,9 @@ "sensor": { "stookwijzer": { "state": { - "code_yellow": "Code yellow", - "code_orange": "Code orange", - "code_red": "Code red" + "blauw": "Blue", + "oranje": "Orange", + "rood": "Red" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 2f88508ce0b..1e570fecc98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2522,7 +2522,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.4.2 +stookwijzer==1.3.0 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6fb4dda4222..2ff6f894285 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1886,7 +1886,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.4.2 +stookwijzer==1.3.0 # homeassistant.components.huawei_lte # homeassistant.components.solaredge From 61d82ae9ab173e0be186b4b361f3144278eb217c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Nov 2023 15:20:21 +0100 Subject: [PATCH 236/643] Tweak dockerfile generation (#104717) --- script/hassfest/docker.py | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 1849e8e7ec8..3bd44736038 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -4,14 +4,14 @@ from homeassistant.util import executor, thread from .model import Config, Integration -DOCKERFILE_TEMPLATE = """# Automatically generated by hassfest. +DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker ARG BUILD_FROM FROM ${{BUILD_FROM}} # Synchronize with homeassistant/core.py:async_stop -ENV \\ +ENV \ S6_SERVICES_GRACETIME={timeout} ARG QEMU_CPU @@ -21,33 +21,33 @@ WORKDIR /usr/src ## Setup Home Assistant Core dependencies COPY requirements.txt homeassistant/ COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ -RUN \\ - pip3 install \\ - --only-binary=:all: \\ +RUN \ + pip3 install \ + --only-binary=:all: \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ -RUN \\ - if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \\ - pip3 install homeassistant/home_assistant_frontend-*.whl; \\ - fi \\ - && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \\ - pip3 install homeassistant/home_assistant_intents-*.whl; \\ - fi \\ - && \\ - LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \\ - MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \\ - pip3 install \\ - --only-binary=:all: \\ +RUN \ + if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \ + pip3 install homeassistant/home_assistant_frontend-*.whl; \ + fi \ + && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \ + pip3 install homeassistant/home_assistant_intents-*.whl; \ + fi \ + && \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ + MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ + pip3 install \ + --only-binary=:all: \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core COPY . homeassistant/ -RUN \\ - pip3 install \\ - --only-binary=:all: \\ - -e ./homeassistant \\ - && python3 -m compileall \\ +RUN \ + pip3 install \ + --only-binary=:all: \ + -e ./homeassistant \ + && python3 -m compileall \ homeassistant/homeassistant # Home Assistant S6-Overlay From c6c8bb69700b7107b055de533c2b3a9dfd9a07eb Mon Sep 17 00:00:00 2001 From: Stefan Rado <628587+kroimon@users.noreply.github.com> Date: Wed, 29 Nov 2023 15:20:40 +0100 Subject: [PATCH 237/643] Bump aioesphomeapi to 19.2.1 (#104703) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 26d15da680b..7eca285681d 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==19.2.0", + "aioesphomeapi==19.2.1", "bluetooth-data-tools==1.15.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 1e570fecc98..a52aa448d7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.0 +aioesphomeapi==19.2.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ff6f894285..a49238e84d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.0 +aioesphomeapi==19.2.1 # homeassistant.components.flo aioflo==2021.11.0 From 36eb858d0aecdf88c1f5c05b825d8d737b1a24ef Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 15:22:21 +0100 Subject: [PATCH 238/643] Rename variable in Epson tests (#104722) --- tests/components/epson/test_media_player.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/epson/test_media_player.py b/tests/components/epson/test_media_player.py index 8d6af04c174..874a12173d6 100644 --- a/tests/components/epson/test_media_player.py +++ b/tests/components/epson/test_media_player.py @@ -30,9 +30,9 @@ async def test_set_unique_id( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.unique_id is None - entity = entity_registry.async_get("media_player.epson") - assert entity - assert entity.unique_id == entry.entry_id + entity_entry = entity_registry.async_get("media_player.epson") + assert entity_entry + assert entity_entry.unique_id == entry.entry_id with patch( "homeassistant.components.epson.Projector.get_power", return_value="01" ), patch( @@ -43,7 +43,7 @@ async def test_set_unique_id( freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() - entity = entity_registry.async_get("media_player.epson") - assert entity - assert entity.unique_id == "123" + entity_entry = entity_registry.async_get("media_player.epson") + assert entity_entry + assert entity_entry.unique_id == "123" assert entry.unique_id == "123" From ba481001c318d301e1a36cb65991de424b3fb251 Mon Sep 17 00:00:00 2001 From: dupondje Date: Wed, 29 Nov 2023 15:41:58 +0100 Subject: [PATCH 239/643] Add support for multiple mbus devices in dsmr (#84097) * Add support for multiple mbus devices in dsmr A dsmr meter can have 4 mbus devices. Support them all and also add support for a water meter on the mbus device. * Apply suggestions from code review Co-authored-by: Jan Bouwhuis * Rewrite old gas sensor to new mbus sensor * No force updates + fix mbus entity unique_id * Remove old gas device * Add additional tests * Fix remarks from last review + move migrated 5b gas meter to new device_id * Fix ruff error * Last fixes --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/dsmr/const.py | 1 + homeassistant/components/dsmr/sensor.py | 199 ++++++-- homeassistant/components/dsmr/strings.json | 3 + tests/components/dsmr/test_mbus_migration.py | 212 +++++++++ tests/components/dsmr/test_sensor.py | 451 ++++++++++++++----- 5 files changed, 720 insertions(+), 146 deletions(-) create mode 100644 tests/components/dsmr/test_mbus_migration.py diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 5e1a54aedc4..ec0623a9ed6 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -29,6 +29,7 @@ DATA_TASK = "task" DEVICE_NAME_ELECTRICITY = "Electricity Meter" DEVICE_NAME_GAS = "Gas Meter" +DEVICE_NAME_WATER = "Water Meter" DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"} diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 3dbd446001f..487f996ac1f 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -34,6 +34,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import CoreState, Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -57,6 +58,7 @@ from .const import ( DEFAULT_TIME_BETWEEN_UPDATE, DEVICE_NAME_ELECTRICITY, DEVICE_NAME_GAS, + DEVICE_NAME_WATER, DOMAIN, DSMR_PROTOCOL, LOGGER, @@ -73,6 +75,7 @@ class DSMRSensorEntityDescription(SensorEntityDescription): dsmr_versions: set[str] | None = None is_gas: bool = False + is_water: bool = False obis_reference: str @@ -374,28 +377,138 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ) -def add_gas_sensor_5B(telegram: dict[str, DSMRObject]) -> DSMRSensorEntityDescription: - """Return correct entity for 5B Gas meter.""" - ref = None - if obis_references.BELGIUM_MBUS1_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS1_METER_READING2 - elif obis_references.BELGIUM_MBUS2_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS2_METER_READING2 - elif obis_references.BELGIUM_MBUS3_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS3_METER_READING2 - elif obis_references.BELGIUM_MBUS4_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS4_METER_READING2 - elif ref is None: - ref = obis_references.BELGIUM_MBUS1_METER_READING2 - return DSMRSensorEntityDescription( - key="belgium_5min_gas_meter_reading", - translation_key="gas_meter_reading", - obis_reference=ref, - dsmr_versions={"5B"}, - is_gas=True, - device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.TOTAL_INCREASING, - ) +def create_mbus_entity( + mbus: int, mtype: int, telegram: dict[str, DSMRObject] +) -> DSMRSensorEntityDescription | None: + """Create a new MBUS Entity.""" + if ( + mtype == 3 + and ( + obis_reference := getattr( + obis_references, f"BELGIUM_MBUS{mbus}_METER_READING2" + ) + ) + in telegram + ): + return DSMRSensorEntityDescription( + key=f"mbus{mbus}_gas_reading", + translation_key="gas_meter_reading", + obis_reference=obis_reference, + is_gas=True, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + if ( + mtype == 7 + and ( + obis_reference := getattr( + obis_references, f"BELGIUM_MBUS{mbus}_METER_READING1" + ) + ) + in telegram + ): + return DSMRSensorEntityDescription( + key=f"mbus{mbus}_water_reading", + translation_key="water_meter_reading", + obis_reference=obis_reference, + is_water=True, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + return None + + +def device_class_and_uom( + telegram: dict[str, DSMRObject], + entity_description: DSMRSensorEntityDescription, +) -> tuple[SensorDeviceClass | None, str | None]: + """Get native unit of measurement from telegram,.""" + dsmr_object = telegram[entity_description.obis_reference] + uom: str | None = getattr(dsmr_object, "unit") or None + with suppress(ValueError): + if entity_description.device_class == SensorDeviceClass.GAS and ( + enery_uom := UnitOfEnergy(str(uom)) + ): + return (SensorDeviceClass.ENERGY, enery_uom) + if uom in UNIT_CONVERSION: + return (entity_description.device_class, UNIT_CONVERSION[uom]) + return (entity_description.device_class, uom) + + +def rename_old_gas_to_mbus( + hass: HomeAssistant, entry: ConfigEntry, mbus_device_id: str +) -> None: + """Rename old gas sensor to mbus variant.""" + dev_reg = dr.async_get(hass) + device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) + if device_entry_v1 is not None: + device_id = device_entry_v1.id + + ent_reg = er.async_get(hass) + entries = er.async_entries_for_device(ent_reg, device_id) + + for entity in entries: + if entity.unique_id.endswith("belgium_5min_gas_meter_reading"): + try: + ent_reg.async_update_entity( + entity.entity_id, + new_unique_id=mbus_device_id, + device_id=mbus_device_id, + ) + except ValueError: + LOGGER.warning( + "Skip migration of %s because it already exists", + entity.entity_id, + ) + else: + LOGGER.info( + "Migrated entity %s from unique id %s to %s", + entity.entity_id, + entity.unique_id, + mbus_device_id, + ) + # Cleanup old device + dev_entities = er.async_entries_for_device( + ent_reg, device_id, include_disabled_entities=True + ) + if not dev_entities: + dev_reg.async_remove_device(device_id) + + +def create_mbus_entities( + hass: HomeAssistant, telegram: dict[str, DSMRObject], entry: ConfigEntry +) -> list[DSMREntity]: + """Create MBUS Entities.""" + entities = [] + for idx in range(1, 5): + if ( + device_type := getattr(obis_references, f"BELGIUM_MBUS{idx}_DEVICE_TYPE") + ) not in telegram: + continue + if (type_ := int(telegram[device_type].value)) not in (3, 7): + continue + if ( + identifier := getattr( + obis_references, + f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER", + ) + ) in telegram: + serial_ = telegram[identifier].value + rename_old_gas_to_mbus(hass, entry, serial_) + else: + serial_ = "" + if description := create_mbus_entity(idx, type_, telegram): + entities.append( + DSMREntity( + description, + entry, + telegram, + *device_class_and_uom(telegram, description), # type: ignore[arg-type] + serial_, + idx, + ) + ) + return entities async def async_setup_entry( @@ -415,25 +528,10 @@ async def async_setup_entry( add_entities_handler() add_entities_handler = None - def device_class_and_uom( - telegram: dict[str, DSMRObject], - entity_description: DSMRSensorEntityDescription, - ) -> tuple[SensorDeviceClass | None, str | None]: - """Get native unit of measurement from telegram,.""" - dsmr_object = telegram[entity_description.obis_reference] - uom: str | None = getattr(dsmr_object, "unit") or None - with suppress(ValueError): - if entity_description.device_class == SensorDeviceClass.GAS and ( - enery_uom := UnitOfEnergy(str(uom)) - ): - return (SensorDeviceClass.ENERGY, enery_uom) - if uom in UNIT_CONVERSION: - return (entity_description.device_class, UNIT_CONVERSION[uom]) - return (entity_description.device_class, uom) - - all_sensors = SENSORS if dsmr_version == "5B": - all_sensors += (add_gas_sensor_5B(telegram),) + mbus_entities = create_mbus_entities(hass, telegram, entry) + for mbus_entity in mbus_entities: + entities.append(mbus_entity) entities.extend( [ @@ -443,7 +541,7 @@ async def async_setup_entry( telegram, *device_class_and_uom(telegram, description), # type: ignore[arg-type] ) - for description in all_sensors + for description in SENSORS if ( description.dsmr_versions is None or dsmr_version in description.dsmr_versions @@ -618,6 +716,8 @@ class DSMREntity(SensorEntity): telegram: dict[str, DSMRObject], device_class: SensorDeviceClass, native_unit_of_measurement: str | None, + serial_id: str = "", + mbus_id: int = 0, ) -> None: """Initialize entity.""" self.entity_description = entity_description @@ -629,8 +729,15 @@ class DSMREntity(SensorEntity): device_serial = entry.data[CONF_SERIAL_ID] device_name = DEVICE_NAME_ELECTRICITY if entity_description.is_gas: - device_serial = entry.data[CONF_SERIAL_ID_GAS] + if serial_id: + device_serial = serial_id + else: + device_serial = entry.data[CONF_SERIAL_ID_GAS] device_name = DEVICE_NAME_GAS + if entity_description.is_water: + if serial_id: + device_serial = serial_id + device_name = DEVICE_NAME_WATER if device_serial is None: device_serial = entry.entry_id @@ -638,7 +745,13 @@ class DSMREntity(SensorEntity): identifiers={(DOMAIN, device_serial)}, name=device_name, ) - self._attr_unique_id = f"{device_serial}_{entity_description.key}" + if mbus_id != 0: + if serial_id: + self._attr_unique_id = f"{device_serial}" + else: + self._attr_unique_id = f"{device_serial}_{mbus_id}" + else: + self._attr_unique_id = f"{device_serial}_{entity_description.key}" @callback def update_data(self, telegram: dict[str, DSMRObject] | None) -> None: diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 5f0568e2905..055c0c41264 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -147,6 +147,9 @@ }, "voltage_swell_l3_count": { "name": "Voltage swells phase L3" + }, + "water_meter_reading": { + "name": "Water consumption" } } }, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py new file mode 100644 index 00000000000..493fd93259f --- /dev/null +++ b/tests/components/dsmr/test_mbus_migration.py @@ -0,0 +1,212 @@ +"""Tests for the DSMR integration.""" +import datetime +from decimal import Decimal + +from homeassistant.components.dsmr.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_migrate_gas_to_mbus( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: + """Test migration of unique_id.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="/dev/ttyUSB0", + data={ + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "precision": 4, + "reconnect_interval": 30, + "serial_id": "1234", + "serial_id_gas": "37464C4F32313139303333373331", + }, + options={ + "time_between_update": 0, + }, + ) + + mock_entry.add_to_hass(hass) + + old_unique_id = "37464C4F32313139303333373331_belgium_5min_gas_meter_reading" + + device_registry = hass.helpers.device_registry.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, mock_entry.entry_id)}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device.id, + unique_id=old_unique_id, + config_entry=mock_entry, + ) + assert entity.unique_id == old_unique_id + await hass.async_block_till_done() + + telegram = { + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + } + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert not dev_entities + + assert ( + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) + is None + ) + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "37464C4F32313139303333373331" + ) + == "sensor.gas_meter_reading" + ) + + +async def test_migrate_gas_to_mbus_exists( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: + """Test migration of unique_id.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="/dev/ttyUSB0", + data={ + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "precision": 4, + "reconnect_interval": 30, + "serial_id": "1234", + "serial_id_gas": "37464C4F32313139303333373331", + }, + options={ + "time_between_update": 0, + }, + ) + + mock_entry.add_to_hass(hass) + + old_unique_id = "37464C4F32313139303333373331_belgium_5min_gas_meter_reading" + + device_registry = hass.helpers.device_registry.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, mock_entry.entry_id)}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device.id, + unique_id=old_unique_id, + config_entry=mock_entry, + ) + assert entity.unique_id == old_unique_id + + device2 = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, "37464C4F32313139303333373331")}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading_alt", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device2.id, + unique_id="37464C4F32313139303333373331", + config_entry=mock_entry, + ) + await hass.async_block_till_done() + + telegram = { + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + } + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) + == "sensor.gas_meter_reading" + ) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 1895dd15dd1..1b7f8efb201 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -8,21 +8,8 @@ import asyncio import datetime from decimal import Decimal from itertools import chain, repeat -from typing import Literal from unittest.mock import DEFAULT, MagicMock -from dsmr_parser.obis_references import ( - BELGIUM_MBUS1_METER_READING1, - BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS2_METER_READING2, - BELGIUM_MBUS3_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - BELGIUM_MBUS4_METER_READING1, - BELGIUM_MBUS4_METER_READING2, -) -import pytest - from homeassistant import config_entries from homeassistant.components.sensor import ( ATTR_OPTIONS, @@ -145,8 +132,8 @@ async def test_default_setup( # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # ensure entities have new state value after incoming telegram power_consumption = hass.states.get("sensor.electricity_meter_power_consumption") @@ -495,10 +482,18 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No from dsmr_parser.obis_references import ( BELGIUM_CURRENT_AVERAGE_DEMAND, BELGIUM_MAXIMUM_DEMAND_MONTH, + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, BELGIUM_MBUS3_METER_READING2, - BELGIUM_MBUS4_METER_READING2, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS4_METER_READING1, ELECTRICITY_ACTIVE_TARIFF, ) from dsmr_parser.objects import CosemObject, MBusObject @@ -509,41 +504,13 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "precision": 4, "reconnect_interval": 30, "serial_id": "1234", - "serial_id_gas": "5678", + "serial_id_gas": None, } entry_options = { "time_between_update": 0, } telegram = { - BELGIUM_MBUS1_METER_READING2: MBusObject( - BELGIUM_MBUS1_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, - ], - ), - BELGIUM_MBUS2_METER_READING2: MBusObject( - BELGIUM_MBUS2_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.696), "unit": "m3"}, - ], - ), - BELGIUM_MBUS3_METER_READING2: MBusObject( - BELGIUM_MBUS3_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(745.697), "unit": "m3"}, - ], - ), - BELGIUM_MBUS4_METER_READING2: MBusObject( - BELGIUM_MBUS4_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(745.698), "unit": "m3"}, - ], - ), BELGIUM_CURRENT_AVERAGE_DEMAND: CosemObject( BELGIUM_CURRENT_AVERAGE_DEMAND, [{"value": Decimal(1.75), "unit": "kW"}], @@ -555,6 +522,62 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No {"value": Decimal(4.11), "unit": "kW"}, ], ), + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS2_METER_READING1: MBusObject( + BELGIUM_MBUS2_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642214)}, + {"value": Decimal(678.695), "unit": "m3"}, + ], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING2: MBusObject( + BELGIUM_MBUS3_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642215)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373334", "unit": ""}], + ), + BELGIUM_MBUS4_METER_READING1: MBusObject( + BELGIUM_MBUS4_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(13.13), "unit": "m3"}, + ], + ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), @@ -600,7 +623,7 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No assert max_demand.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.KILO_WATT assert max_demand.attributes.get(ATTR_STATE_CLASS) is None - # check if gas consumption is parsed correctly + # check if gas consumption mbus1 is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS @@ -613,48 +636,69 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No == UnitOfVolume.CUBIC_METERS ) + # check if water usage mbus2 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "678.695" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) -@pytest.mark.parametrize( - ("key1", "key2", "key3", "gas_value"), - [ - ( - BELGIUM_MBUS1_METER_READING1, - BELGIUM_MBUS2_METER_READING2, - BELGIUM_MBUS3_METER_READING1, - "745.696", - ), - ( - BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - "745.695", - ), - ( - BELGIUM_MBUS4_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING1, - "745.695", - ), - ( - BELGIUM_MBUS4_METER_READING1, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - "745.697", - ), - ], -) -async def test_belgian_meter_alt( - hass: HomeAssistant, - dsmr_connection_fixture, - key1: Literal, - key2: Literal, - key3: Literal, - gas_value: str, -) -> None: + # check if gas consumption mbus1 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption.state == "12.12" + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if water usage mbus2 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption.state == "13.13" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + +async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.objects import MBusObject + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING1, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_METER_READING1, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS4_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject entry_data = { "port": "/dev/ttyUSB0", @@ -662,32 +706,67 @@ async def test_belgian_meter_alt( "precision": 4, "reconnect_interval": 30, "serial_id": "1234", - "serial_id_gas": "5678", + "serial_id_gas": None, } entry_options = { "time_between_update": 0, } telegram = { - key1: MBusObject( - key1, - [ - {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, - ], + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "007", "unit": ""}] ), - key2: MBusObject( - key2, - [ - {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.696), "unit": "m3"}, - ], + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], ), - key3: MBusObject( - key3, + BELGIUM_MBUS1_METER_READING1: MBusObject( + BELGIUM_MBUS1_METER_READING1, [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(745.697), "unit": "m3"}, + {"value": Decimal(123.456), "unit": "m3"}, + ], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS2_METER_READING2: MBusObject( + BELGIUM_MBUS2_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(678.901), "unit": "m3"}, + ], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING1: MBusObject( + BELGIUM_MBUS3_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642217)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373334", "unit": ""}], + ), + BELGIUM_MBUS4_METER_READING2: MBusObject( + BELGIUM_MBUS4_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"value": Decimal(13.13), "unit": "m3"}, ], ), } @@ -709,9 +788,24 @@ async def test_belgian_meter_alt( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() - # check if gas consumption is parsed correctly + # check if water usage mbus1 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "123.456" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if gas consumption mbus2 is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == gas_value + assert gas_consumption.state == "678.901" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) @@ -722,6 +816,157 @@ async def test_belgian_meter_alt( == UnitOfVolume.CUBIC_METERS ) + # check if water usage mbus3 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption.state == "12.12" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if gas consumption mbus4 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption.state == "13.13" + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + +async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) -> None: + """Test if Belgian meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_METER_READING2, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_METER_READING1, + ELECTRICITY_ACTIVE_TARIFF, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "precision": 4, + "reconnect_interval": 30, + "serial_id": "1234", + "serial_id_gas": None, + } + entry_options = { + "time_between_update": 0, + } + + telegram = { + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0003", "unit": ""}] + ), + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "006", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING2: MBusObject( + BELGIUM_MBUS3_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642217)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS4_METER_READING1: MBusObject( + BELGIUM_MBUS4_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"value": Decimal(13.13), "unit": "m3"}, + ], + ), + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + # tariff should be translated in human readable and have no unit + active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") + assert active_tariff.state == "unknown" + + # check if gas consumption mbus2 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") + assert gas_consumption is None + + # check if water usage mbus3 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption is None + + # check if gas consumption mbus4 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption is None + + # check if gas consumption mbus4 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "13.13" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if Belgian meter is correctly parsed.""" From 608f4f7c5250ac0838358bd636552190290de64c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Nov 2023 09:13:54 -0600 Subject: [PATCH 240/643] Bump aiohttp to 3.9.1 (#104176) --- homeassistant/components/hassio/http.py | 4 ++++ homeassistant/components/hassio/ingress.py | 2 +- homeassistant/helpers/aiohttp_client.py | 2 +- homeassistant/package_constraints.txt | 3 +-- pyproject.toml | 3 +-- requirements.txt | 3 +-- tests/components/generic/test_camera.py | 16 ++++------------ tests/util/test_aiohttp.py | 22 +++++----------------- 8 files changed, 18 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 419d80484cf..9d72d5842fd 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -6,6 +6,7 @@ from http import HTTPStatus import logging import os import re +from typing import TYPE_CHECKING from urllib.parse import quote, unquote import aiohttp @@ -156,6 +157,9 @@ class HassIOView(HomeAssistantView): # _stored_content_type is only computed once `content_type` is accessed if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary + if TYPE_CHECKING: + # pylint: disable-next=protected-access + assert isinstance(request._stored_content_type, str) # pylint: disable-next=protected-access headers[CONTENT_TYPE] = request._stored_content_type diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index b29f80ff2b3..7da6f044db0 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -172,7 +172,7 @@ class HassIOIngress(HomeAssistantView): content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED) # Avoid parsing content_type in simple cases for better performance if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE): - content_type = (maybe_content_type.partition(";"))[0].strip() + content_type: str = (maybe_content_type.partition(";"))[0].strip() else: content_type = result.content_type # Simple request diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index fba9bb647dd..74527a5922f 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -298,7 +298,7 @@ def _async_get_connector( return connectors[connector_key] if verify_ssl: - ssl_context: bool | SSLContext = ssl_util.get_default_context() + ssl_context: SSLContext = ssl_util.get_default_context() else: ssl_context = ssl_util.get_default_no_verify_context() diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4aa2a4ea013..73f12f8db90 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,8 +3,7 @@ aiodiscover==1.5.1 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.1.1 -aiohttp==3.8.5;python_version<'3.12' -aiohttp==3.9.0;python_version>='3.12' +aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.36.2 diff --git a/pyproject.toml b/pyproject.toml index b05f5f5f9dc..71e58bf2177 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,8 +23,7 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp==3.9.0;python_version>='3.12'", - "aiohttp==3.8.5;python_version<'3.12'", + "aiohttp==3.9.1", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", "aiohttp-zlib-ng==0.1.1", diff --git a/requirements.txt b/requirements.txt index f26c1a84e81..aa9a0ab0e5a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,8 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.9.0;python_version>='3.12' -aiohttp==3.8.5;python_version<'3.12' +aiohttp==3.9.1 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.1.1 diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index aecfcbc29c1..8bfd0a66dd5 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -1,7 +1,6 @@ """The tests for generic camera component.""" import asyncio from http import HTTPStatus -import sys from unittest.mock import patch import aiohttp @@ -164,17 +163,10 @@ async def test_limit_refetch( hass.states.async_set("sensor.temp", "5") - # TODO: Remove version check with aiohttp 3.9.0 - if sys.version_info >= (3, 12): - with pytest.raises(aiohttp.ServerTimeoutError), patch( - "asyncio.timeout", side_effect=asyncio.TimeoutError() - ): - resp = await client.get("/api/camera_proxy/camera.config_test") - else: - with pytest.raises(aiohttp.ServerTimeoutError), patch( - "async_timeout.timeout", side_effect=asyncio.TimeoutError() - ): - resp = await client.get("/api/camera_proxy/camera.config_test") + with pytest.raises(aiohttp.ServerTimeoutError), patch( + "asyncio.timeout", side_effect=asyncio.TimeoutError() + ): + resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 1 assert resp.status == HTTPStatus.OK diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index bfdc3c3e949..ada0269ac0e 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -1,5 +1,4 @@ """Test aiohttp request helper.""" -import sys from aiohttp import web @@ -50,22 +49,11 @@ def test_serialize_text() -> None: def test_serialize_body_str() -> None: """Test serializing a response with a str as body.""" response = web.Response(status=201, body="Hello") - # TODO: Remove version check with aiohttp 3.9.0 - if sys.version_info >= (3, 12): - assert aiohttp.serialize_response(response) == { - "status": 201, - "body": "Hello", - "headers": {"Content-Type": "text/plain; charset=utf-8"}, - } - else: - assert aiohttp.serialize_response(response) == { - "status": 201, - "body": "Hello", - "headers": { - "Content-Length": "5", - "Content-Type": "text/plain; charset=utf-8", - }, - } + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": "Hello", + "headers": {"Content-Type": "text/plain; charset=utf-8"}, + } def test_serialize_body_None() -> None: From e2bab699b599a1198af787db14fdb8251304e90a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 29 Nov 2023 16:24:30 +0100 Subject: [PATCH 241/643] Avoid double refresh when adding entities in bsblan (#104647) --- homeassistant/components/bsblan/climate.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 39eab6e7e0a..609d5ab6e83 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -60,8 +60,7 @@ async def async_setup_entry( data.static, entry, ) - ], - True, + ] ) From 4628b036776649dbe1dacea3a908be817eb8d529 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Nov 2023 16:29:20 +0100 Subject: [PATCH 242/643] Update frontend to 20231129.1 (#104723) --- 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 6a6c557c7b7..7a587d56d74 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==20231129.0"] + "requirements": ["home-assistant-frontend==20231129.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 73f12f8db90..acb65b6fce8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231129.0 +home-assistant-frontend==20231129.1 home-assistant-intents==2023.11.17 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index a52aa448d7d..b3c498a5f12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.0 +home-assistant-frontend==20231129.1 # homeassistant.components.conversation home-assistant-intents==2023.11.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a49238e84d7..8dc991f9a61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.0 +home-assistant-frontend==20231129.1 # homeassistant.components.conversation home-assistant-intents==2023.11.17 From 82264a0d6b30426fd79ad8413f843241ce8a5f23 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 16:29:42 +0100 Subject: [PATCH 243/643] Fix mqtt cover state is open after receiving stopped payload (#104726) --- homeassistant/components/mqtt/cover.py | 6 +++++- tests/components/mqtt/test_cover.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c8da14e67e6..4e8cf0f4129 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -380,7 +380,11 @@ class MqttCover(MqttEntity, CoverEntity): else STATE_OPEN ) else: - state = STATE_CLOSED if self.state == STATE_CLOSING else STATE_OPEN + state = ( + STATE_CLOSED + if self.state in [STATE_CLOSED, STATE_CLOSING] + else STATE_OPEN + ) elif payload == self._config[CONF_STATE_OPENING]: state = STATE_OPENING elif payload == self._config[CONF_STATE_CLOSING]: diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index f3bf92951b0..8db1c89bc40 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -3347,6 +3347,11 @@ async def test_set_state_via_stopped_state_no_position_topic( state = hass.states.get("cover.test") assert state.state == STATE_CLOSED + async_fire_mqtt_message(hass, "state-topic", "STOPPED") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSED + @pytest.mark.parametrize( "hass_config", From b36ddaa15c5975fbae36e2b4eef9721ef45f1a35 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 16:30:23 +0100 Subject: [PATCH 244/643] Change super class order in Withings Calendar (#104721) --- homeassistant/components/withings/calendar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index 19572682d1a..132f00936f3 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -66,7 +66,7 @@ def get_event_name(category: WorkoutCategory) -> str: class WithingsWorkoutCalendarEntity( - CalendarEntity, WithingsEntity[WithingsWorkoutDataUpdateCoordinator] + WithingsEntity[WithingsWorkoutDataUpdateCoordinator], CalendarEntity ): """A calendar entity.""" From 4bf88b1690f9d720a012b98b49fbbd5b0dd40c13 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 16:42:10 +0100 Subject: [PATCH 245/643] Improve MQTT json light brightness scaling (#104510) * Improve MQTT json light brightness scaling * Revert unrelated changes * Format --- homeassistant/components/mqtt/light/schema_json.py | 7 ++----- tests/components/mqtt/test_light_json.py | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 2a2a262be36..3d2957f153d 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -367,13 +367,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): if brightness_supported(self.supported_color_modes): try: if brightness := values["brightness"]: + scale = self._config[CONF_BRIGHTNESS_SCALE] self._attr_brightness = min( - int( - brightness # type: ignore[operator] - / float(self._config[CONF_BRIGHTNESS_SCALE]) - * 255 - ), 255, + round(brightness * 255 / scale), # type: ignore[operator] ) else: _LOGGER.debug( diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index b3dd3a9a4e3..82b0b3467f4 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -1792,7 +1792,7 @@ async def test_brightness_scale( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 128 + assert state.attributes.get("brightness") == 129 # Test limmiting max brightness async_fire_mqtt_message( @@ -1862,7 +1862,7 @@ async def test_white_scale( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 128 + assert state.attributes.get("brightness") == 129 @pytest.mark.parametrize( From 47426a3ddca7b19e785ddaa6c151148f03f0a862 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 29 Nov 2023 16:56:26 +0100 Subject: [PATCH 246/643] Remove redundant websocket_api exception handler (#104727) --- homeassistant/components/websocket_api/commands.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 5edf5018938..cb90b46e182 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -794,9 +794,6 @@ async def handle_execute_script( translation_placeholders=err.translation_placeholders, ) return - except Exception as exc: # pylint: disable=broad-except - connection.async_handle_exception(msg, exc) - return connection.send_result( msg["id"], { From a894146cee389078d5b04760eadbd0465bf8181c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Nov 2023 11:07:22 -0600 Subject: [PATCH 247/643] Fix TTS streaming for VoIP (#104620) * Use wav instead of raw tts audio in voip * More tests * Use mock TTS dir --- homeassistant/components/voip/voip.py | 29 +++- tests/components/voip/test_voip.py | 218 +++++++++++++++++++++++++- 2 files changed, 239 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 120f2d9559b..74bc94e7dc5 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -5,10 +5,12 @@ import asyncio from collections import deque from collections.abc import AsyncIterable, MutableSequence, Sequence from functools import partial +import io import logging from pathlib import Path import time from typing import TYPE_CHECKING +import wave from voip_utils import ( CallInfo, @@ -285,7 +287,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): ), conversation_id=self._conversation_id, device_id=self.voip_device.device_id, - tts_audio_output="raw", + tts_audio_output="wav", ) if self._pipeline_error: @@ -402,11 +404,32 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): if self.transport is None: return - _extension, audio_bytes = await tts.async_get_media_source_audio( + extension, data = await tts.async_get_media_source_audio( self.hass, media_id, ) + if extension != "wav": + raise ValueError(f"Only WAV audio can be streamed, got {extension}") + + with io.BytesIO(data) as wav_io: + with wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + + if ( + (sample_rate != 16000) + or (sample_width != 2) + or (sample_channels != 1) + ): + raise ValueError( + "Expected rate/width/channels as 16000/2/1," + " got {sample_rate}/{sample_width}/{sample_channels}}" + ) + + audio_bytes = wav_file.readframes(wav_file.getnframes()) + _LOGGER.debug("Sending %s byte(s) of audio", len(audio_bytes)) # Time out 1 second after TTS audio should be finished @@ -414,7 +437,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): tts_seconds = tts_samples / RATE async with asyncio.timeout(tts_seconds + self.tts_extra_timeout): - # Assume TTS audio is 16Khz 16-bit mono + # TTS audio is 16Khz 16-bit mono await self._async_send_audio(audio_bytes) except asyncio.TimeoutError as err: _LOGGER.warning("TTS timeout") diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index f82a00087c6..692896c6dfa 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -1,7 +1,9 @@ """Test VoIP protocol.""" import asyncio +import io import time from unittest.mock import AsyncMock, Mock, patch +import wave import pytest @@ -14,6 +16,24 @@ _ONE_SECOND = 16000 * 2 # 16Khz 16-bit _MEDIA_ID = "12345" +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + +def _empty_wav() -> bytes: + """Return bytes of an empty WAV file.""" + with io.BytesIO() as wav_io: + wav_file: wave.Wave_write = wave.open(wav_io, "wb") + with wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + + return wav_io.getvalue() + + async def test_pipeline( hass: HomeAssistant, voip_device: VoIPDevice, @@ -72,8 +92,7 @@ async def test_pipeline( media_source_id: str, ) -> tuple[str, bytes]: assert media_source_id == _MEDIA_ID - - return ("mp3", b"") + return ("wav", _empty_wav()) with patch( "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", @@ -266,7 +285,7 @@ async def test_tts_timeout( media_source_id: str, ) -> tuple[str, bytes]: # Should time out immediately - return ("raw", bytes(0)) + return ("wav", _empty_wav()) with patch( "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", @@ -305,8 +324,197 @@ async def test_tts_timeout( done.set() - rtp_protocol._async_send_audio = AsyncMock(side_effect=async_send_audio) - rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) + rtp_protocol._async_send_audio = AsyncMock(side_effect=async_send_audio) # type: ignore[method-assign] + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to exhaust the audio stream + async with asyncio.timeout(1): + await done.wait() + + +async def test_tts_wrong_extension( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will only stream WAV audio.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + # Should fail because it's not "wav" + return ("mp3", b"") + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + original_send_tts = rtp_protocol._send_tts + + async def send_tts(*args, **kwargs): + # Call original then end test successfully + with pytest.raises(ValueError): + await original_send_tts(*args, **kwargs) + + done.set() + + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to exhaust the audio stream + async with asyncio.timeout(1): + await done.wait() + + +async def test_tts_wrong_wav_format( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will only stream WAV audio with a specific format.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + # Should fail because it's not 16Khz, 16-bit mono + with io.BytesIO() as wav_io: + wav_file: wave.Wave_write = wave.open(wav_io, "wb") + with wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(2) + + return ("wav", wav_io.getvalue()) + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + original_send_tts = rtp_protocol._send_tts + + async def send_tts(*args, **kwargs): + # Call original then end test successfully + with pytest.raises(ValueError): + await original_send_tts(*args, **kwargs) + + done.set() + + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] # silence rtp_protocol.on_chunk(bytes(_ONE_SECOND)) From 2287c45afcef31e28aa51ef42f2392fda9651ad9 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Wed, 29 Nov 2023 18:11:04 +0100 Subject: [PATCH 248/643] Bump bimmer-connected to 0.14.5 (#104715) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bmw_connected_drive/snapshots/test_diagnostics.ambr | 5 ----- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 911a998371e..1ebf52e52ae 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.14.3"] + "requirements": ["bimmer-connected[china]==0.14.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index b3c498a5f12..0ac2ddbecea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -526,7 +526,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.3 +bimmer-connected[china]==0.14.5 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8dc991f9a61..9307034b794 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -448,7 +448,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.3 +bimmer-connected[china]==0.14.5 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 32405d93e6b..b3af5bc59b6 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -413,7 +413,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -1288,7 +1287,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -1979,7 +1977,6 @@ 'charging_settings': dict({ }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -2734,7 +2731,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', @@ -5070,7 +5066,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', From dfed10420caf4c3ba1823553196b5a0a601ac5c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Nov 2023 10:24:34 -0700 Subject: [PATCH 249/643] Remove aiohttp enable_compression helper (#104174) --- homeassistant/components/api/__init__.py | 7 ++++--- homeassistant/components/hassio/ingress.py | 4 ++-- homeassistant/components/http/view.py | 4 ++-- homeassistant/helpers/aiohttp_compat.py | 18 +----------------- 4 files changed, 9 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 7e4966e2b0d..057e85613fd 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -41,7 +41,6 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions @@ -218,9 +217,11 @@ class APIStatesView(HomeAssistantView): if entity_perm(state.entity_id, "read") ) response = web.Response( - body=f'[{",".join(states)}]', content_type=CONTENT_TYPE_JSON + body=f'[{",".join(states)}]', + content_type=CONTENT_TYPE_JSON, + zlib_executor_size=32768, ) - enable_compression(response) + response.enable_compression() return response diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 7da6f044db0..751e9005809 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -17,7 +17,6 @@ from yarl import URL from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.typing import UNDEFINED from .const import X_HASS_SOURCE, X_INGRESS_PATH @@ -188,11 +187,12 @@ class HassIOIngress(HomeAssistantView): status=result.status, content_type=content_type, body=body, + zlib_executor_size=32768, ) if content_length_int > MIN_COMPRESSED_SIZE and should_compress( content_type or simple_response.content_type ): - enable_compression(simple_response) + simple_response.enable_compression() await simple_response.prepare(request) return simple_response diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 7481381bbc8..1be3d761a3b 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -20,7 +20,6 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant, is_callback -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.json import ( find_paths_unserializable_data, json_bytes, @@ -72,8 +71,9 @@ class HomeAssistantView: content_type=CONTENT_TYPE_JSON, status=int(status_code), headers=headers, + zlib_executor_size=32768, ) - enable_compression(response) + response.enable_compression() return response def json_message( diff --git a/homeassistant/helpers/aiohttp_compat.py b/homeassistant/helpers/aiohttp_compat.py index 6e281b659fe..78aad44fa66 100644 --- a/homeassistant/helpers/aiohttp_compat.py +++ b/homeassistant/helpers/aiohttp_compat.py @@ -1,7 +1,7 @@ """Helper to restore old aiohttp behavior.""" from __future__ import annotations -from aiohttp import web, web_protocol, web_server +from aiohttp import web_protocol, web_server class CancelOnDisconnectRequestHandler(web_protocol.RequestHandler): @@ -23,19 +23,3 @@ def restore_original_aiohttp_cancel_behavior() -> None: """ web_protocol.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] web_server.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] - - -def enable_compression(response: web.Response) -> None: - """Enable compression on the response.""" - # - # Set _zlib_executor_size in the constructor once support for - # aiohttp < 3.9.0 is dropped - # - # We want large zlib payloads to be compressed in the executor - # to avoid blocking the event loop. - # - # 32KiB was chosen based on testing in production. - # aiohttp will generate a warning for payloads larger than 1MiB - # - response._zlib_executor_size = 32768 # pylint: disable=protected-access - response.enable_compression() From 38eda9f46e305cddd9bf6690dce50ef4ca975e02 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 29 Nov 2023 18:32:32 +0100 Subject: [PATCH 250/643] Add multiple option to text selector (#104635) Co-authored-by: Robert Resch --- homeassistant/helpers/selector.py | 12 +++++++++--- tests/helpers/test_config_validation.py | 1 + tests/helpers/test_selector.py | 5 +++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index bda2440cfb3..f7ceb4ab812 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1206,6 +1206,7 @@ class TextSelectorConfig(TypedDict, total=False): suffix: str type: TextSelectorType autocomplete: str + multiple: bool class TextSelectorType(StrEnum): @@ -1243,6 +1244,7 @@ class TextSelector(Selector[TextSelectorConfig]): vol.Coerce(TextSelectorType), lambda val: val.value ), vol.Optional("autocomplete"): str, + vol.Optional("multiple", default=False): bool, } ) @@ -1250,10 +1252,14 @@ class TextSelector(Selector[TextSelectorConfig]): """Instantiate a selector.""" super().__init__(config) - def __call__(self, data: Any) -> str: + def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" - text: str = vol.Schema(str)(data) - return text + if not self.config["multiple"]: + text: str = vol.Schema(str)(data) + return text + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return [vol.Schema(str)(val) for val in data] class ThemeSelectorConfig(TypedDict): diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index a9ddd89a0b3..6d1945f2d5f 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -832,6 +832,7 @@ def test_selector_in_serializer() -> None: "selector": { "text": { "multiline": False, + "multiple": False, } } } diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 93c342384fd..c4ad244620b 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -602,6 +602,11 @@ def test_object_selector_schema(schema, valid_selections, invalid_selections) -> ({"multiline": True}, (), ()), ({"multiline": False, "type": "email"}, (), ()), ({"prefix": "before", "suffix": "after"}, (), ()), + ( + {"multiple": True}, + (["abc123", "def456"],), + ("abc123", None, ["abc123", None]), + ), ), ) def test_text_selector_schema(schema, valid_selections, invalid_selections) -> None: From 1727c19e0deb1af006dbfe966aaeb208dfeed680 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 29 Nov 2023 18:35:55 +0100 Subject: [PATCH 251/643] Address review comments for Picnic (#104732) --- homeassistant/components/picnic/todo.py | 7 +++---- tests/components/picnic/conftest.py | 20 ++------------------ tests/components/picnic/test_todo.py | 3 ++- 3 files changed, 7 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py index 47b9685c9ec..fea99f7403d 100644 --- a/homeassistant/components/picnic/todo.py +++ b/homeassistant/components/picnic/todo.py @@ -12,6 +12,7 @@ from homeassistant.components.todo import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -31,7 +32,7 @@ async def async_setup_entry( """Set up the Picnic shopping cart todo platform config entry.""" picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] - async_add_entities([PicnicCart(hass, picnic_coordinator, config_entry)]) + async_add_entities([PicnicCart(picnic_coordinator, config_entry)]) class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): @@ -44,7 +45,6 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): def __init__( self, - hass: HomeAssistant, coordinator: PicnicUpdateCoordinator, config_entry: ConfigEntry, ) -> None: @@ -56,7 +56,6 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): manufacturer="Picnic", model=config_entry.unique_id, ) - self.hass = hass self._attr_unique_id = f"{config_entry.unique_id}-cart" @property @@ -87,7 +86,7 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): ) if not product_id: - raise ValueError("No product found or no product ID given") + raise ServiceValidationError("No product found or no product ID given") await self.hass.async_add_executor_job( self.coordinator.picnic_api_client.add_product, product_id, 1 diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py index fb6c99f35e9..1ca6413fc42 100644 --- a/tests/components/picnic/conftest.py +++ b/tests/components/picnic/conftest.py @@ -56,31 +56,16 @@ async def init_integration( return mock_config_entry -@pytest.fixture -def ws_req_id() -> Callable[[], int]: - """Fixture for incremental websocket requests.""" - - id = 0 - - def next_id() -> int: - nonlocal id - id += 1 - return id - - return next_id - - @pytest.fixture async def get_items( - hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] + hass_ws_client: WebSocketGenerator ) -> Callable[[], Awaitable[dict[str, str]]]: """Fixture to fetch items from the todo websocket.""" async def get() -> list[dict[str, str]]: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() - await client.send_json( + await client.send_json_auto_id( { "id": id, "type": "todo/item/list", @@ -88,7 +73,6 @@ async def get_items( } ) resp = await client.receive_json() - assert resp.get("id") == id assert resp.get("success") return resp.get("result", {}).get("items", []) diff --git a/tests/components/picnic/test_todo.py b/tests/components/picnic/test_todo.py index a65fb83ca95..cdd30967058 100644 --- a/tests/components/picnic/test_todo.py +++ b/tests/components/picnic/test_todo.py @@ -7,6 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.todo import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .conftest import ENTITY_ID @@ -115,7 +116,7 @@ async def test_create_todo_list_item_not_found( mock_picnic_api.search = Mock() mock_picnic_api.search.return_value = [{"items": []}] - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, "add_item", From 1b048ff388c047a0741bd3a3d1a5677a788943e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Nov 2023 10:40:19 -0700 Subject: [PATCH 252/643] Remove HomeAssistantAccessLogger (#104173) --- .../components/emulated_hue/__init__.py | 3 +-- homeassistant/components/http/__init__.py | 24 +------------------ tests/components/http/test_init.py | 23 +----------------- 3 files changed, 3 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index a98d2c08a48..1ba93da716c 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -6,7 +6,6 @@ import logging from aiohttp import web import voluptuous as vol -from homeassistant.components.http import HomeAssistantAccessLogger from homeassistant.components.network import async_get_source_ip from homeassistant.const import ( CONF_ENTITIES, @@ -101,7 +100,7 @@ async def start_emulated_hue_bridge( config.advertise_port or config.listen_port, ) - runner = web.AppRunner(app, access_log_class=HomeAssistantAccessLogger) + runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 5a1d182e80c..3ccfa18ef12 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -16,7 +16,6 @@ from aiohttp.http_parser import RawRequestMessage from aiohttp.streams import StreamReader from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection -from aiohttp.web_log import AccessLogger from aiohttp.web_protocol import RequestHandler from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher from aiohttp_zlib_ng import enable_zlib_ng @@ -238,25 +237,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class HomeAssistantAccessLogger(AccessLogger): - """Access logger for Home Assistant that does not log when disabled.""" - - def log( - self, request: web.BaseRequest, response: web.StreamResponse, time: float - ) -> None: - """Log the request. - - The default implementation logs the request to the logger - with the INFO level and than throws it away if the logger - is not enabled for the INFO level. This implementation - does not log the request if the logger is not enabled for - the INFO level. - """ - if not self.logger.isEnabledFor(logging.INFO): - return - super().log(request, response, time) - - class HomeAssistantRequest(web.Request): """Home Assistant request object.""" @@ -540,9 +520,7 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner( - self.app, access_log_class=HomeAssistantAccessLogger - ) + self.runner = web.AppRunner(self.app) await self.runner.setup() self.site = HomeAssistantTCPSite( diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 5a5bffe6748..97e39811cd8 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -5,8 +5,7 @@ from http import HTTPStatus from ipaddress import ip_network import logging from pathlib import Path -import time -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest @@ -21,7 +20,6 @@ from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMockResponse from tests.typing import ClientSessionGenerator @@ -501,22 +499,3 @@ 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_hass_access_logger_at_info_level( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test that logging happens at info level.""" - test_logger = logging.getLogger("test.aiohttp.logger") - logger = http.HomeAssistantAccessLogger(test_logger) - mock_request = MagicMock() - response = AiohttpClientMockResponse( - "POST", "http://127.0.0.1", status=HTTPStatus.OK - ) - setattr(response, "body_length", 42) - logger.log(mock_request, response, time.time()) - assert "42" in caplog.text - caplog.clear() - test_logger.setLevel(logging.WARNING) - logger.log(mock_request, response, time.time()) - assert "42" not in caplog.text From e10d58ef3e8d1362fbeb7858bf6cf2b62753a8c9 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Nov 2023 11:52:27 -0600 Subject: [PATCH 253/643] Bump intents to 2023.11.29 (#104738) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index e99334b5c37..2a069d5d92b 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.17"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.29"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index acb65b6fce8..75209de5996 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 home-assistant-frontend==20231129.1 -home-assistant-intents==2023.11.17 +home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0ac2ddbecea..ec0e5f232e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1017,7 +1017,7 @@ holidays==0.36 home-assistant-frontend==20231129.1 # homeassistant.components.conversation -home-assistant-intents==2023.11.17 +home-assistant-intents==2023.11.29 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9307034b794..75947854c7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -804,7 +804,7 @@ holidays==0.36 home-assistant-frontend==20231129.1 # homeassistant.components.conversation -home-assistant-intents==2023.11.17 +home-assistant-intents==2023.11.29 # homeassistant.components.home_connect homeconnect==0.7.2 From 1fefa936484d80ca7a5eafda411b0bd944039137 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 19:03:58 +0100 Subject: [PATCH 254/643] Use config entry callbacks in Gree (#104740) --- homeassistant/components/gree/__init__.py | 15 ++++----------- homeassistant/components/gree/climate.py | 5 ++--- homeassistant/components/gree/const.py | 1 - homeassistant/components/gree/switch.py | 6 +++--- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index ff3438ed53f..13e93d780b2 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -11,7 +11,6 @@ from homeassistant.helpers.event import async_track_time_interval from .bridge import DiscoveryService from .const import ( COORDINATORS, - DATA_DISCOVERY_INTERVAL, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL, DISPATCHERS, @@ -29,7 +28,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gree_discovery = DiscoveryService(hass) hass.data[DATA_DISCOVERY_SERVICE] = gree_discovery - hass.data[DOMAIN].setdefault(DISPATCHERS, []) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def _async_scan_update(_=None): @@ -39,8 +37,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Scanning network for Gree devices") await _async_scan_update() - hass.data[DOMAIN][DATA_DISCOVERY_INTERVAL] = async_track_time_interval( - hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + entry.async_on_unload( + async_track_time_interval( + hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + ) ) return True @@ -48,13 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if hass.data[DOMAIN].get(DISPATCHERS) is not None: - for cleanup in hass.data[DOMAIN][DISPATCHERS]: - cleanup() - - if hass.data[DOMAIN].get(DATA_DISCOVERY_INTERVAL) is not None: - hass.data[DOMAIN].pop(DATA_DISCOVERY_INTERVAL)() - if hass.data.get(DATA_DISCOVERY_SERVICE) is not None: hass.data.pop(DATA_DISCOVERY_SERVICE) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index b14b9cfaba4..ba162173724 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -47,7 +47,6 @@ from .bridge import DeviceDataUpdateCoordinator from .const import ( COORDINATORS, DISPATCH_DEVICE_DISCOVERED, - DISPATCHERS, DOMAIN, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, @@ -88,7 +87,7 @@ SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @@ -101,7 +100,7 @@ async def async_setup_entry( for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) - hass.data[DOMAIN][DISPATCHERS].append( + entry.async_on_unload( async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) ) diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index b4df7a1acde..46479210921 100644 --- a/homeassistant/components/gree/const.py +++ b/homeassistant/components/gree/const.py @@ -3,7 +3,6 @@ COORDINATORS = "coordinators" DATA_DISCOVERY_SERVICE = "gree_discovery" -DATA_DISCOVERY_INTERVAL = "gree_discovery_interval" DISCOVERY_SCAN_INTERVAL = 300 DISCOVERY_TIMEOUT = 8 diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 68c11ad6e1f..7916df18abc 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN from .entity import GreeEntity @@ -102,7 +102,7 @@ GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @@ -119,7 +119,7 @@ async def async_setup_entry( for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) - hass.data[DOMAIN][DISPATCHERS].append( + entry.async_on_unload( async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) ) From 50f2c411456b8f53b199d7782328da0c8e8138be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Nov 2023 11:20:22 -0700 Subject: [PATCH 255/643] Avoid db hit and executor job for impossible history queries (#104724) --- homeassistant/components/history/__init__.py | 5 +++-- homeassistant/components/history/helpers.py | 9 +++++++++ homeassistant/components/history/websocket_api.py | 5 +++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index f5b97a7fb13..9eab92dce5c 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -21,7 +21,7 @@ import homeassistant.util.dt as dt_util from . import websocket_api from .const import DOMAIN -from .helpers import entities_may_have_state_changes_after +from .helpers import entities_may_have_state_changes_after, has_recorder_run_after CONF_ORDER = "use_include_order" @@ -106,7 +106,8 @@ class HistoryPeriodView(HomeAssistantView): no_attributes = "no_attributes" in request.query if ( - not include_start_time_state + (end_time and not has_recorder_run_after(hass, end_time)) + or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( hass, entity_ids, start_time, no_attributes diff --git a/homeassistant/components/history/helpers.py b/homeassistant/components/history/helpers.py index 523b1fafb7f..7e28e69e5f9 100644 --- a/homeassistant/components/history/helpers.py +++ b/homeassistant/components/history/helpers.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import process_timestamp from homeassistant.core import HomeAssistant @@ -21,3 +23,10 @@ def entities_may_have_state_changes_after( return True return False + + +def has_recorder_run_after(hass: HomeAssistant, run_time: dt) -> bool: + """Check if the recorder has any runs after a specific time.""" + return run_time >= process_timestamp( + get_instance(hass).recorder_runs_manager.first.start + ) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 24ec07b6a87..4be63f29c02 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -39,7 +39,7 @@ from homeassistant.helpers.typing import EventType import homeassistant.util.dt as dt_util from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES -from .helpers import entities_may_have_state_changes_after +from .helpers import entities_may_have_state_changes_after, has_recorder_run_after _LOGGER = logging.getLogger(__name__) @@ -142,7 +142,8 @@ async def ws_get_history_during_period( no_attributes = msg["no_attributes"] if ( - not include_start_time_state + (end_time and not has_recorder_run_after(hass, end_time)) + or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( hass, entity_ids, start_time, no_attributes From 1522118453972b9396435dd695987f9dace47aab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Nov 2023 11:20:36 -0700 Subject: [PATCH 256/643] Remove aiohttp cancel on disconnect workaround (#104175) --- homeassistant/components/http/__init__.py | 2 +- homeassistant/core.py | 2 - homeassistant/helpers/aiohttp_compat.py | 25 ----------- tests/helpers/test_aiohttp_compat.py | 55 ----------------------- 4 files changed, 1 insertion(+), 83 deletions(-) delete mode 100644 homeassistant/helpers/aiohttp_compat.py delete mode 100644 tests/helpers/test_aiohttp_compat.py diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 3ccfa18ef12..449f00fb335 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -520,7 +520,7 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner(self.app) + self.runner = web.AppRunner(self.app, handler_cancellation=True) await self.runner.setup() self.site = HomeAssistantTCPSite( diff --git a/homeassistant/core.py b/homeassistant/core.py index 972e0e618e6..7d9d8d19b49 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -80,7 +80,6 @@ from .exceptions import ( ServiceNotFound, Unauthorized, ) -from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior from .helpers.json import json_dumps from .util import dt as dt_util, location from .util.async_ import ( @@ -113,7 +112,6 @@ STAGE_2_SHUTDOWN_TIMEOUT = 60 STAGE_3_SHUTDOWN_TIMEOUT = 30 block_async_io.enable() -restore_original_aiohttp_cancel_behavior() _T = TypeVar("_T") _R = TypeVar("_R") diff --git a/homeassistant/helpers/aiohttp_compat.py b/homeassistant/helpers/aiohttp_compat.py deleted file mode 100644 index 78aad44fa66..00000000000 --- a/homeassistant/helpers/aiohttp_compat.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Helper to restore old aiohttp behavior.""" -from __future__ import annotations - -from aiohttp import web_protocol, web_server - - -class CancelOnDisconnectRequestHandler(web_protocol.RequestHandler): - """Request handler that cancels tasks on disconnect.""" - - def connection_lost(self, exc: BaseException | None) -> None: - """Handle connection lost.""" - task_handler = self._task_handler - super().connection_lost(exc) - if task_handler is not None: - task_handler.cancel("aiohttp connection lost") - - -def restore_original_aiohttp_cancel_behavior() -> None: - """Patch aiohttp to restore cancel behavior. - - Remove this once aiohttp 3.9 is released as we can use - https://github.com/aio-libs/aiohttp/pull/7128 - """ - web_protocol.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] - web_server.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] diff --git a/tests/helpers/test_aiohttp_compat.py b/tests/helpers/test_aiohttp_compat.py deleted file mode 100644 index 749984dbc2e..00000000000 --- a/tests/helpers/test_aiohttp_compat.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Test the aiohttp compatibility shim.""" - -import asyncio -from contextlib import suppress - -from aiohttp import client, web, web_protocol, web_server -import pytest - -from homeassistant.helpers.aiohttp_compat import CancelOnDisconnectRequestHandler - - -@pytest.mark.allow_hosts(["127.0.0.1"]) -async def test_handler_cancellation(socket_enabled, unused_tcp_port_factory) -> None: - """Test that handler cancels the request on disconnect. - - From aiohttp tests/test_web_server.py - """ - assert web_protocol.RequestHandler is CancelOnDisconnectRequestHandler - assert web_server.RequestHandler is CancelOnDisconnectRequestHandler - - event = asyncio.Event() - port = unused_tcp_port_factory() - - async def on_request(_: web.Request) -> web.Response: - nonlocal event - try: - await asyncio.sleep(10) - except asyncio.CancelledError: - event.set() - raise - else: - raise web.HTTPInternalServerError() - - app = web.Application() - app.router.add_route("GET", "/", on_request) - - runner = web.AppRunner(app) - await runner.setup() - - site = web.TCPSite(runner, host="127.0.0.1", port=port) - - await site.start() - - try: - async with client.ClientSession( - timeout=client.ClientTimeout(total=0.1) - ) as sess: - with pytest.raises(asyncio.TimeoutError): - await sess.get(f"http://127.0.0.1:{port}/") - - with suppress(asyncio.TimeoutError): - await asyncio.wait_for(event.wait(), timeout=1) - assert event.is_set(), "Request handler hasn't been cancelled" - finally: - await asyncio.gather(runner.shutdown(), site.stop()) From af2f8699b7436bf74cfe803b9b460939b82f43ec Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 10:35:36 -0800 Subject: [PATCH 257/643] Add due date and description to CalDAV To-do (#104656) Co-authored-by: Martin Hjelmare Co-authored-by: Robert Resch --- homeassistant/components/caldav/todo.py | 46 ++++++--- tests/components/caldav/test_todo.py | 120 +++++++++++++++++++++--- 2 files changed, 142 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index eddfe410100..1bd24dc542a 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import date, datetime, timedelta from functools import partial import logging -from typing import cast +from typing import Any, cast import caldav from caldav.lib.error import DAVError, NotFoundError @@ -21,6 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util from .api import async_get_calendars, get_attr_value from .const import DOMAIN @@ -71,6 +72,12 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: or (summary := get_attr_value(todo, "summary")) is None ): return None + due: date | datetime | None = None + if due_value := get_attr_value(todo, "due"): + if isinstance(due_value, datetime): + due = dt_util.as_local(due_value) + elif isinstance(due_value, date): + due = due_value return TodoItem( uid=uid, summary=summary, @@ -78,9 +85,28 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: get_attr_value(todo, "status") or "", TodoItemStatus.NEEDS_ACTION, ), + due=due, + description=get_attr_value(todo, "description"), ) +def _to_ics_fields(item: TodoItem) -> dict[str, Any]: + """Convert a TodoItem to the set of add or update arguments.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["summary"] = summary + if status := item.status: + item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") + if due := item.due: + if isinstance(due, datetime): + item_data["due"] = dt_util.as_utc(due).strftime("%Y%m%dT%H%M%SZ") + else: + item_data["due"] = due.strftime("%Y%m%d") + if description := item.description: + item_data["description"] = description + return item_data + + class WebDavTodoListEntity(TodoListEntity): """CalDAV To-do list entity.""" @@ -89,6 +115,9 @@ class WebDavTodoListEntity(TodoListEntity): TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None: @@ -116,13 +145,7 @@ class WebDavTodoListEntity(TodoListEntity): """Add an item to the To-do list.""" try: await self.hass.async_add_executor_job( - partial( - self._calendar.save_todo, - summary=item.summary, - status=TODO_STATUS_MAP_INV.get( - item.status or TodoItemStatus.NEEDS_ACTION, "NEEDS-ACTION" - ), - ), + partial(self._calendar.save_todo, **_to_ics_fields(item)), ) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -139,10 +162,7 @@ class WebDavTodoListEntity(TodoListEntity): except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err vtodo = todo.icalendar_component # type: ignore[attr-defined] - if item.summary: - vtodo["summary"] = item.summary - if item.status: - vtodo["status"] = TODO_STATUS_MAP_INV.get(item.status, "NEEDS-ACTION") + vtodo.update(**_to_ics_fields(item)) try: await self.hass.async_add_executor_job( partial( diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 55ae0d564d0..6e92f211463 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -17,7 +17,7 @@ from tests.typing import WebSocketGenerator CALENDAR_NAME = "My Tasks" ENTITY_NAME = "My tasks" TEST_ENTITY = "todo.my_tasks" -SUPPORTED_FEATURES = 7 +SUPPORTED_FEATURES = 119 TODO_NO_STATUS = """BEGIN:VCALENDAR VERSION:2.0 @@ -40,6 +40,12 @@ STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" +RESULT_ITEM = { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", +} + TODO_COMPLETED = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//E-Corp.//CalDAV Client//EN @@ -69,6 +75,12 @@ def platforms() -> list[Platform]: return [Platform.TODO] +@pytest.fixture(autouse=True) +def set_tz(hass: HomeAssistant) -> None: + """Fixture to set timezone with fixed offset year round.""" + hass.config.set_time_zone("America/Regina") + + @pytest.fixture(name="todos") def mock_todos() -> list[str]: """Fixture to return VTODO objects for the calendar.""" @@ -178,10 +190,49 @@ async def test_supported_components( assert (state is not None) == has_entity +@pytest.mark.parametrize( + ("item_data", "expcted_save_args", "expected_item"), + [ + ( + {}, + {"status": "NEEDS-ACTION", "summary": "Cheese"}, + RESULT_ITEM, + ), + ( + {"due_date": "2023-11-18"}, + {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118"}, + {**RESULT_ITEM, "due": "2023-11-18"}, + ), + ( + {"due_datetime": "2023-11-18T08:30:00-06:00"}, + {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118T143000Z"}, + {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, + ), + ( + {"description": "Make sure to get Swiss"}, + { + "status": "NEEDS-ACTION", + "summary": "Cheese", + "description": "Make sure to get Swiss", + }, + {**RESULT_ITEM, "description": "Make sure to get Swiss"}, + ), + ], + ids=[ + "summary", + "due_date", + "due_datetime", + "description", + ], +) async def test_add_item( hass: HomeAssistant, config_entry: MockConfigEntry, + dav_client: Mock, calendar: Mock, + item_data: dict[str, Any], + expcted_save_args: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test adding an item to the list.""" calendar.search.return_value = [] @@ -197,16 +248,13 @@ async def test_add_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Cheese"}, + {"item": "Cheese", **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) assert calendar.save_todo.call_args - assert calendar.save_todo.call_args.kwargs == { - "status": "NEEDS-ACTION", - "summary": "Cheese", - } + assert calendar.save_todo.call_args.kwargs == expcted_save_args # Verify state was updated state = hass.states.get(TEST_ENTITY) @@ -235,20 +283,59 @@ async def test_add_item_failure( @pytest.mark.parametrize( - ("update_data", "expected_ics", "expected_state"), + ("update_data", "expected_ics", "expected_state", "expected_item"), [ ( {"rename": "Swiss Cheese"}, ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], "1", + {**RESULT_ITEM, "summary": "Swiss Cheese"}, + ), + ( + {"status": "needs_action"}, + ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], + "1", + RESULT_ITEM, + ), + ( + {"status": "completed"}, + ["SUMMARY:Cheese", "STATUS:COMPLETED"], + "0", + {**RESULT_ITEM, "status": "completed"}, ), - ({"status": "needs_action"}, ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], "1"), - ({"status": "completed"}, ["SUMMARY:Cheese", "STATUS:COMPLETED"], "0"), ( {"rename": "Swiss Cheese", "status": "needs_action"}, ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], "1", + {**RESULT_ITEM, "summary": "Swiss Cheese"}, ), + ( + {"due_date": "2023-11-18"}, + ["SUMMARY:Cheese", "DUE:20231118"], + "1", + {**RESULT_ITEM, "due": "2023-11-18"}, + ), + ( + {"due_datetime": "2023-11-18T08:30:00-06:00"}, + ["SUMMARY:Cheese", "DUE:20231118T143000Z"], + "1", + {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, + ), + ( + {"description": "Make sure to get Swiss"}, + ["SUMMARY:Cheese", "DESCRIPTION:Make sure to get Swiss"], + "1", + {**RESULT_ITEM, "description": "Make sure to get Swiss"}, + ), + ], + ids=[ + "rename", + "status_needs_action", + "status_completed", + "rename_status", + "due_date", + "due_datetime", + "description", ], ) async def test_update_item( @@ -259,8 +346,9 @@ async def test_update_item( update_data: dict[str, Any], expected_ics: list[str], expected_state: str, + expected_item: dict[str, Any], ) -> None: - """Test creating a an item on the list.""" + """Test updating an item on the list.""" item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") calendar.search = MagicMock(return_value=[item]) @@ -295,6 +383,16 @@ async def test_update_item( assert state assert state.state == expected_state + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + return_response=True, + ) + assert result == {TEST_ENTITY: {"items": [expected_item]}} + async def test_update_item_failure( hass: HomeAssistant, @@ -506,7 +604,7 @@ async def test_subscribe( calendar: Mock, hass_ws_client: WebSocketGenerator, ) -> None: - """Test creating a an item on the list.""" + """Test subscription to item updates.""" item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") calendar.search = MagicMock(return_value=[item]) From 19f543214f6d19e51c2c21d9fffdc004ab7d025a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 20:40:14 +0100 Subject: [PATCH 258/643] Bump version to 2023.12.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c6655ba3900..1fbd97159a7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 71e58bf2177..bd1168d11de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0.dev0" +version = "2023.12.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 0a98a648bb185c48a459b30242afe15e2fc1eb5b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 21:29:11 +0100 Subject: [PATCH 259/643] Bump version to 2024.1.0dev0 (#104746) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 4 ++-- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 71030e50074..b9b9c8babb9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,7 +36,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 6 - HA_SHORT_VERSION: "2023.12" + HA_SHORT_VERSION: "2024.1" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index c6655ba3900..8da1c251b4e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,8 +5,8 @@ from enum import StrEnum from typing import Final APPLICATION_NAME: Final = "HomeAssistant" -MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 12 +MAJOR_VERSION: Final = 2024 +MINOR_VERSION: Final = 1 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 71e58bf2177..844fac7142f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0.dev0" +version = "2024.1.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From bdf4c61a052a2e2b970b95e759f4c3e3e04737ff Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 21:41:32 +0100 Subject: [PATCH 260/643] Add faa_delays to strict typing (#104720) --- .strict-typing | 1 + .../components/faa_delays/binary_sensor.py | 56 +++++++++++-------- .../components/faa_delays/config_flow.py | 6 +- .../components/faa_delays/coordinator.py | 8 +-- mypy.ini | 10 ++++ 5 files changed, 54 insertions(+), 27 deletions(-) diff --git a/.strict-typing b/.strict-typing index 3c18a1988f3..2c8717596c6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -120,6 +120,7 @@ homeassistant.components.energy.* homeassistant.components.esphome.* homeassistant.components.event.* homeassistant.components.evil_genius_labs.* +homeassistant.components.faa_delays.* homeassistant.components.fan.* homeassistant.components.fastdotcom.* homeassistant.components.feedreader.* diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 5cbb206f223..5a209a41de1 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -1,8 +1,12 @@ """Platform for FAA Delays sensor component.""" from __future__ import annotations +from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import Any +from faadelays import Airport + from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, @@ -12,33 +16,47 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import FAADataUpdateCoordinator from .const import DOMAIN -FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( + +@dataclass(kw_only=True) +class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): + """Mixin for required keys.""" + + is_on_fn: Callable[[Airport], bool | None] + + +FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( + FaaDelaysBinarySensorEntityDescription( key="GROUND_DELAY", name="Ground Delay", icon="mdi:airport", + is_on_fn=lambda airport: airport.ground_delay.status, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="GROUND_STOP", name="Ground Stop", icon="mdi:airport", + is_on_fn=lambda airport: airport.ground_stop.status, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="DEPART_DELAY", name="Departure Delay", icon="mdi:airplane-takeoff", + is_on_fn=lambda airport: airport.depart_delay.status, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="ARRIVE_DELAY", name="Arrival Delay", icon="mdi:airplane-landing", + is_on_fn=lambda airport: airport.arrive_delay.status, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="CLOSURE", name="Closure", icon="mdi:airplane:off", + is_on_fn=lambda airport: airport.closure.status, ), ) @@ -57,11 +75,16 @@ async def async_setup_entry( async_add_entities(entities) -class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): +class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorEntity): """Define a binary sensor for FAA Delays.""" + entity_description: FaaDelaysBinarySensorEntityDescription + def __init__( - self, coordinator, entry_id, description: BinarySensorEntityDescription + self, + coordinator: FAADataUpdateCoordinator, + entry_id: str, + description: FaaDelaysBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -75,23 +98,12 @@ class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): self._attr_unique_id = f"{_id}_{description.key}" @property - def is_on(self): + def is_on(self) -> bool | None: """Return the status of the sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "GROUND_DELAY": - return self.coordinator.data.ground_delay.status - if sensor_type == "GROUND_STOP": - return self.coordinator.data.ground_stop.status - if sensor_type == "DEPART_DELAY": - return self.coordinator.data.depart_delay.status - if sensor_type == "ARRIVE_DELAY": - return self.coordinator.data.arrive_delay.status - if sensor_type == "CLOSURE": - return self.coordinator.data.closure.status - return None + return self.entity_description.is_on_fn(self.coordinator.data) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return attributes for sensor.""" sensor_type = self.entity_description.key if sensor_type == "GROUND_DELAY": diff --git a/homeassistant/components/faa_delays/config_flow.py b/homeassistant/components/faa_delays/config_flow.py index b2f7f69dd49..2f91ce9f797 100644 --- a/homeassistant/components/faa_delays/config_flow.py +++ b/homeassistant/components/faa_delays/config_flow.py @@ -1,5 +1,6 @@ """Config flow for FAA Delays integration.""" import logging +from typing import Any from aiohttp import ClientConnectionError import faadelays @@ -7,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_ID +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN @@ -21,7 +23,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: diff --git a/homeassistant/components/faa_delays/coordinator.py b/homeassistant/components/faa_delays/coordinator.py index f2aefdada66..2f110cf7730 100644 --- a/homeassistant/components/faa_delays/coordinator.py +++ b/homeassistant/components/faa_delays/coordinator.py @@ -6,6 +6,7 @@ import logging from aiohttp import ClientConnectionError from faadelays import Airport +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -14,19 +15,18 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class FAADataUpdateCoordinator(DataUpdateCoordinator): +class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]): """Class to manage fetching FAA API data from a single endpoint.""" - def __init__(self, hass, code): + def __init__(self, hass: HomeAssistant, code: str) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) ) self.session = aiohttp_client.async_get_clientsession(hass) self.data = Airport(code, self.session) - self.code = code - async def _async_update_data(self): + async def _async_update_data(self) -> Airport: try: async with asyncio.timeout(10): await self.data.update() diff --git a/mypy.ini b/mypy.ini index 0ed06edaa1d..e01be53db3d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -961,6 +961,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.faa_delays.*] +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.fan.*] check_untyped_defs = true disallow_incomplete_defs = true From 7638b9bba1ad57f6d1f4e88e9f09bcec577ad490 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 29 Nov 2023 21:54:05 +0100 Subject: [PATCH 261/643] Fix Philips TV none recordings_list (#104665) Correct for missing recordings list in api client. --------- Co-authored-by: Joakim Plate --- .../components/philips_js/binary_sensor.py | 2 ++ .../philips_js/test_binary_sensor.py | 31 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index 78aa9f17b05..1e6c1241aea 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -66,6 +66,8 @@ async def async_setup_entry( def _check_for_recording_entry(api: PhilipsTV, entry: str, value: str) -> bool: """Return True if at least one specified value is available within entry of list.""" + if api.recordings_list is None: + return False for rec in api.recordings_list["recordings"]: if rec.get(entry) == value: return True diff --git a/tests/components/philips_js/test_binary_sensor.py b/tests/components/philips_js/test_binary_sensor.py index d11f3fe22f1..01233706d07 100644 --- a/tests/components/philips_js/test_binary_sensor.py +++ b/tests/components/philips_js/test_binary_sensor.py @@ -1,7 +1,7 @@ """The tests for philips_js binary_sensor.""" import pytest -from homeassistant.const import STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from . import MOCK_NAME, MOCK_RECORDINGS_LIST @@ -32,7 +32,16 @@ async def mock_tv_api_valid(mock_tv): return mock_tv -async def test_recordings_list_invalid( +@pytest.fixture +async def mock_tv_recordings_list_unavailable(mock_tv): + """Set up a valid mock_tv with should create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 6 + mock_tv.recordings_list = None + return mock_tv + + +async def test_recordings_list_api_invalid( mock_tv_api_invalid, mock_config_entry, hass: HomeAssistant ) -> None: """Test if sensors are not created if mock_tv is invalid.""" @@ -54,7 +63,21 @@ async def test_recordings_list_valid( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) state = hass.states.get(ID_RECORDING_AVAILABLE) - assert state.state is STATE_ON + assert state.state == STATE_ON state = hass.states.get(ID_RECORDING_ONGOING) - assert state.state is STATE_ON + assert state.state == STATE_ON + + +async def test_recordings_list_unavailable( + mock_tv_recordings_list_unavailable, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are created correctly.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state.state == STATE_OFF + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state.state == STATE_OFF From 9126b00dfe3fedc5a98d6e61064f08a73a9f6959 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 22:01:03 +0100 Subject: [PATCH 262/643] Move Faa delays state attributes to entity description (#104748) --- .../components/faa_delays/binary_sensor.py | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 5a209a41de1..e8d7a03752f 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -25,6 +25,7 @@ class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): """Mixin for required keys.""" is_on_fn: Callable[[Airport], bool | None] + extra_state_attributes_fn: Callable[[Airport], Mapping[str, Any]] FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( @@ -33,30 +34,54 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( name="Ground Delay", icon="mdi:airport", is_on_fn=lambda airport: airport.ground_delay.status, + extra_state_attributes_fn=lambda airport: { + "average": airport.ground_delay.average, + "reason": airport.ground_delay.reason, + }, ), FaaDelaysBinarySensorEntityDescription( key="GROUND_STOP", name="Ground Stop", icon="mdi:airport", is_on_fn=lambda airport: airport.ground_stop.status, + extra_state_attributes_fn=lambda airport: { + "endtime": airport.ground_stop.endtime, + "reason": airport.ground_stop.reason, + }, ), FaaDelaysBinarySensorEntityDescription( key="DEPART_DELAY", name="Departure Delay", icon="mdi:airplane-takeoff", is_on_fn=lambda airport: airport.depart_delay.status, + extra_state_attributes_fn=lambda airport: { + "minimum": airport.depart_delay.minimum, + "maximum": airport.depart_delay.maximum, + "trend": airport.depart_delay.trend, + "reason": airport.depart_delay.reason, + }, ), FaaDelaysBinarySensorEntityDescription( key="ARRIVE_DELAY", name="Arrival Delay", icon="mdi:airplane-landing", is_on_fn=lambda airport: airport.arrive_delay.status, + extra_state_attributes_fn=lambda airport: { + "minimum": airport.arrive_delay.minimum, + "maximum": airport.arrive_delay.maximum, + "trend": airport.arrive_delay.trend, + "reason": airport.arrive_delay.reason, + }, ), FaaDelaysBinarySensorEntityDescription( key="CLOSURE", name="Closure", icon="mdi:airplane:off", is_on_fn=lambda airport: airport.closure.status, + extra_state_attributes_fn=lambda airport: { + "begin": airport.closure.start, + "end": airport.closure.end, + }, ), ) @@ -89,10 +114,6 @@ class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorE """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - - self.coordinator = coordinator - self._entry_id = entry_id - self._attrs: dict[str, Any] = {} _id = coordinator.data.code self._attr_name = f"{_id} {description.name}" self._attr_unique_id = f"{_id}_{description.key}" @@ -105,24 +126,4 @@ class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorE @property def extra_state_attributes(self) -> Mapping[str, Any]: """Return attributes for sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "GROUND_DELAY": - self._attrs["average"] = self.coordinator.data.ground_delay.average - self._attrs["reason"] = self.coordinator.data.ground_delay.reason - elif sensor_type == "GROUND_STOP": - self._attrs["endtime"] = self.coordinator.data.ground_stop.endtime - self._attrs["reason"] = self.coordinator.data.ground_stop.reason - elif sensor_type == "DEPART_DELAY": - self._attrs["minimum"] = self.coordinator.data.depart_delay.minimum - self._attrs["maximum"] = self.coordinator.data.depart_delay.maximum - self._attrs["trend"] = self.coordinator.data.depart_delay.trend - self._attrs["reason"] = self.coordinator.data.depart_delay.reason - elif sensor_type == "ARRIVE_DELAY": - self._attrs["minimum"] = self.coordinator.data.arrive_delay.minimum - self._attrs["maximum"] = self.coordinator.data.arrive_delay.maximum - self._attrs["trend"] = self.coordinator.data.arrive_delay.trend - self._attrs["reason"] = self.coordinator.data.arrive_delay.reason - elif sensor_type == "CLOSURE": - self._attrs["begin"] = self.coordinator.data.closure.start - self._attrs["end"] = self.coordinator.data.closure.end - return self._attrs + return self.entity_description.extra_state_attributes_fn(self.coordinator.data) From 8e64eff62620bc3744fab55b031ece11706205be Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 29 Nov 2023 22:23:46 +0100 Subject: [PATCH 263/643] Add entity translations to faa_delays (#104749) --- .../components/faa_delays/binary_sensor.py | 19 +++-- .../components/faa_delays/strings.json | 71 +++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index e8d7a03752f..c72fedaf59a 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -31,7 +32,7 @@ class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( FaaDelaysBinarySensorEntityDescription( key="GROUND_DELAY", - name="Ground Delay", + translation_key="ground_delay", icon="mdi:airport", is_on_fn=lambda airport: airport.ground_delay.status, extra_state_attributes_fn=lambda airport: { @@ -41,7 +42,7 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( ), FaaDelaysBinarySensorEntityDescription( key="GROUND_STOP", - name="Ground Stop", + translation_key="ground_stop", icon="mdi:airport", is_on_fn=lambda airport: airport.ground_stop.status, extra_state_attributes_fn=lambda airport: { @@ -51,7 +52,7 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( ), FaaDelaysBinarySensorEntityDescription( key="DEPART_DELAY", - name="Departure Delay", + translation_key="depart_delay", icon="mdi:airplane-takeoff", is_on_fn=lambda airport: airport.depart_delay.status, extra_state_attributes_fn=lambda airport: { @@ -63,7 +64,7 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( ), FaaDelaysBinarySensorEntityDescription( key="ARRIVE_DELAY", - name="Arrival Delay", + translation_key="arrive_delay", icon="mdi:airplane-landing", is_on_fn=lambda airport: airport.arrive_delay.status, extra_state_attributes_fn=lambda airport: { @@ -75,7 +76,7 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( ), FaaDelaysBinarySensorEntityDescription( key="CLOSURE", - name="Closure", + translation_key="closure", icon="mdi:airplane:off", is_on_fn=lambda airport: airport.closure.status, extra_state_attributes_fn=lambda airport: { @@ -103,6 +104,8 @@ async def async_setup_entry( class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorEntity): """Define a binary sensor for FAA Delays.""" + _attr_has_entity_name = True + entity_description: FaaDelaysBinarySensorEntityDescription def __init__( @@ -117,6 +120,12 @@ class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorE _id = coordinator.data.code self._attr_name = f"{_id} {description.name}" self._attr_unique_id = f"{_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, _id)}, + name=_id, + manufacturer="Federal Aviation Administration", + entry_type=DeviceEntryType.SERVICE, + ) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/faa_delays/strings.json b/homeassistant/components/faa_delays/strings.json index 92a9dafb4da..145c9e3ab34 100644 --- a/homeassistant/components/faa_delays/strings.json +++ b/homeassistant/components/faa_delays/strings.json @@ -17,5 +17,76 @@ "abort": { "already_configured": "This airport is already configured." } + }, + "entity": { + "binary_sensor": { + "ground_delay": { + "name": "Ground delay", + "state_attributes": { + "average": { + "name": "Average" + }, + "reason": { + "name": "Reason" + } + } + }, + "ground_stop": { + "name": "Ground stop", + "state_attributes": { + "endtime": { + "name": "End time" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "depart_delay": { + "name": "Departure delay", + "state_attributes": { + "minimum": { + "name": "Minimum" + }, + "maximum": { + "name": "Maximum" + }, + "trend": { + "name": "Trend" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "arrive_delay": { + "name": "Arrival delay", + "state_attributes": { + "minimum": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::minimum::name%]" + }, + "maximum": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::maximum::name%]" + }, + "trend": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::trend::name%]" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "closure": { + "name": "Closure", + "state_attributes": { + "begin": { + "name": "Begin" + }, + "end": { + "name": "End" + } + } + } + } } } From c8aed064384d335be2ab33da57137515083742b0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 13:37:43 -0800 Subject: [PATCH 264/643] Add due date and description to Google Tasks (#104654) * Add tests for config validation function * Add Google Tasks due date and description * Revert test timezone * Update changes after upstream * Update homeassistant/components/google_tasks/todo.py Co-authored-by: Martin Hjelmare * Add google tasks tests for creating --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/google_tasks/todo.py | 39 ++++++--- .../google_tasks/snapshots/test_todo.ambr | 58 ++++++++++--- tests/components/google_tasks/test_todo.py | 85 +++++++++---------- 3 files changed, 114 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index d3c4dfa6936..130c0d2cc01 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -1,7 +1,7 @@ """Google Tasks todo platform.""" from __future__ import annotations -from datetime import timedelta +from datetime import date, datetime, timedelta from typing import Any, cast from homeassistant.components.todo import ( @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .api import AsyncConfigEntryAuth from .const import DOMAIN @@ -35,9 +36,31 @@ def _convert_todo_item(item: TodoItem) -> dict[str, str]: result["title"] = item.summary if item.status is not None: result["status"] = TODO_STATUS_MAP_INV[item.status] + if (due := item.due) is not None: + # due API field is a timestamp string, but with only date resolution + result["due"] = dt_util.start_of_local_day(due).isoformat() + if (description := item.description) is not None: + result["notes"] = description return result +def _convert_api_item(item: dict[str, str]) -> TodoItem: + """Convert tasks API items into a TodoItem.""" + due: date | None = None + if (due_str := item.get("due")) is not None: + due = datetime.fromisoformat(due_str).date() + return TodoItem( + summary=item["title"], + uid=item["id"], + status=TODO_STATUS_MAP.get( + item.get("status", ""), + TodoItemStatus.NEEDS_ACTION, + ), + due=due, + description=item.get("notes"), + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -68,6 +91,8 @@ class GoogleTaskTodoListEntity( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -88,17 +113,7 @@ class GoogleTaskTodoListEntity( """Get the current set of To-do items.""" if self.coordinator.data is None: return None - return [ - TodoItem( - summary=item["title"], - uid=item["id"], - status=TODO_STATUS_MAP.get( - item.get("status"), # type: ignore[arg-type] - TodoItemStatus.NEEDS_ACTION, - ), - ) - for item in _order_tasks(self.coordinator.data) - ] + return [_convert_api_item(item) for item in _order_tasks(self.coordinator.data)] async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index 73289b313d9..7d6eb920593 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -1,11 +1,29 @@ # serializer version: 1 -# name: test_create_todo_list_item[api_responses0] +# name: test_create_todo_list_item[description] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', 'POST', ) # --- -# name: test_create_todo_list_item[api_responses0].1 +# name: test_create_todo_list_item[description].1 + '{"title": "Soda", "status": "needsAction", "notes": "6-pack"}' +# --- +# name: test_create_todo_list_item[due] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[due].1 + '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_create_todo_list_item[summary] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[summary].1 '{"title": "Soda", "status": "needsAction"}' # --- # name: test_delete_todo_list_item[_handler] @@ -38,6 +56,33 @@ }), ]) # --- +# name: test_partial_update[description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[description].1 + '{"notes": "6-pack"}' +# --- +# name: test_partial_update[due_date] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[due_date].1 + '{"due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_partial_update[rename] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[rename].1 + '{"title": "Soda"}' +# --- # name: test_partial_update_status[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', @@ -47,15 +92,6 @@ # name: test_partial_update_status[api_responses0].1 '{"status": "needsAction"}' # --- -# name: test_partial_update_title[api_responses0] - tuple( - 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', - 'PATCH', - ) -# --- -# name: test_partial_update_title[api_responses0].1 - '{"title": "Soda"}' -# --- # name: test_update_todo_list_item[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 0b82815b33a..3329f89c1ca 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -19,13 +19,12 @@ from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator ENTITY_ID = "todo.my_tasks" +ITEM = { + "id": "task-list-id-1", + "title": "My tasks", +} LIST_TASK_LIST_RESPONSE = { - "items": [ - { - "id": "task-list-id-1", - "title": "My tasks", - }, - ] + "items": [ITEM], } EMPTY_RESPONSE = {} LIST_TASKS_RESPONSE = { @@ -76,6 +75,20 @@ LIST_TASKS_RESPONSE_MULTIPLE = { ], } +# API responses when testing update methods +UPDATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update +] +CREATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # create + LIST_TASKS_RESPONSE, # refresh +] + @pytest.fixture def platforms() -> list[str]: @@ -207,12 +220,14 @@ def mock_http_response(response_handler: list | Callable) -> Mock: "title": "Task 1", "status": "needsAction", "position": "0000000000000001", + "due": "2023-11-18T00:00:00+00:00", }, { "id": "task-2", "title": "Task 2", "status": "completed", "position": "0000000000000002", + "notes": "long description", }, ], }, @@ -238,11 +253,13 @@ async def test_get_items( "uid": "task-1", "summary": "Task 1", "status": "needs_action", + "due": "2023-11-18", }, { "uid": "task-2", "summary": "Task 2", "status": "completed", + "description": "long description", }, ] @@ -333,21 +350,20 @@ async def test_task_items_error_response( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, - EMPTY_RESPONSE, # create - LIST_TASKS_RESPONSE, # refresh after delete - ] + (CREATE_API_RESPONSES, {}), + (CREATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (CREATE_API_RESPONSES, {"description": "6-pack"}), ], + ids=["summary", "due", "description"], ) async def test_create_todo_list_item( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Mock, + item_data: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for creating a To-do Item.""" @@ -361,7 +377,7 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -407,17 +423,7 @@ async def test_create_todo_list_item_error( ) -@pytest.mark.parametrize( - "api_responses", - [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] - ], -) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_update_todo_list_item( hass: HomeAssistant, setup_credentials: None, @@ -483,21 +489,20 @@ async def test_update_todo_list_item_error( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] + (UPDATE_API_RESPONSES, {"rename": "Soda"}), + (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (UPDATE_API_RESPONSES, {"description": "6-pack"}), ], + ids=("rename", "due_date", "description"), ) -async def test_partial_update_title( +async def test_partial_update( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Any, + item_data: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for partial update with title only.""" @@ -511,7 +516,7 @@ async def test_partial_update_title( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "some-task-id", "rename": "Soda"}, + {"item": "some-task-id", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -522,17 +527,7 @@ async def test_partial_update_title( assert call.kwargs.get("body") == snapshot -@pytest.mark.parametrize( - "api_responses", - [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] - ], -) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_partial_update_status( hass: HomeAssistant, setup_credentials: None, From 2437fb314f80d75da85750824473cbbfc83a2a0d Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 30 Nov 2023 00:05:10 +0200 Subject: [PATCH 265/643] Add strict typing to push bullet (#99538) Co-authored-by: Franck Nijhof --- .strict-typing | 1 + homeassistant/components/pushbullet/api.py | 3 ++- homeassistant/components/pushbullet/notify.py | 9 ++++++--- mypy.ini | 10 ++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index 2c8717596c6..a4969bcc810 100644 --- a/.strict-typing +++ b/.strict-typing @@ -265,6 +265,7 @@ homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* homeassistant.components.purpleair.* +homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* homeassistant.components.radarr.* diff --git a/homeassistant/components/pushbullet/api.py b/homeassistant/components/pushbullet/api.py index ff6a57aa931..691ef7413c3 100644 --- a/homeassistant/components/pushbullet/api.py +++ b/homeassistant/components/pushbullet/api.py @@ -1,4 +1,5 @@ """Pushbullet Notification provider.""" +from __future__ import annotations from typing import Any @@ -10,7 +11,7 @@ from homeassistant.helpers.dispatcher import dispatcher_send from .const import DATA_UPDATED -class PushBulletNotificationProvider(Listener): +class PushBulletNotificationProvider(Listener): # type: ignore[misc] """Provider for an account, leading to one or more sensors.""" def __init__(self, hass: HomeAssistant, pushbullet: PushBullet) -> None: diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index 1cc851bdb99..662240d0bf5 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .api import PushBulletNotificationProvider from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -34,8 +35,10 @@ async def async_get_service( """Get the Pushbullet notification service.""" if TYPE_CHECKING: assert discovery_info is not None - pushbullet: PushBullet = hass.data[DOMAIN][discovery_info["entry_id"]].pushbullet - return PushBulletNotificationService(hass, pushbullet) + pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN][ + discovery_info["entry_id"] + ] + return PushBulletNotificationService(hass, pb_provider.pushbullet) class PushBulletNotificationService(BaseNotificationService): @@ -120,7 +123,7 @@ class PushBulletNotificationService(BaseNotificationService): pusher: PushBullet, email: str | None = None, phonenumber: str | None = None, - ): + ) -> None: """Create the message content.""" kwargs = {"body": message, "title": title} if email: diff --git a/mypy.ini b/mypy.ini index e01be53db3d..a27282fc667 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2411,6 +2411,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pushbullet.*] +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.pvoutput.*] check_untyped_defs = true disallow_incomplete_defs = true From e8091a47db337f06ee10846ac272069566546fe7 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 29 Nov 2023 17:13:05 -0500 Subject: [PATCH 266/643] Bump pynws to 1.6.0 (#104679) --- homeassistant/components/nws/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 05194d85a26..4006a145db4 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], "quality_scale": "platinum", - "requirements": ["pynws==1.5.1"] + "requirements": ["pynws==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ec0e5f232e2..d19df8c1c9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1922,7 +1922,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75947854c7f..5bf8f26c874 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1451,7 +1451,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 From ec3795ab974a7f3853dce3c8798965aea8b61aab Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 23:16:58 +0100 Subject: [PATCH 267/643] Revert "Add proj dependency to our wheels builder (#104699)" (#104704) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 9d16954cd09..3b23f1b5b05 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -199,7 +199,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From 04f1b11ef723b468517b68530474e95ff382d8f6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Nov 2023 00:32:12 +0100 Subject: [PATCH 268/643] Debug level logging for DSMR migration code (#104757) --- homeassistant/components/dsmr/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 487f996ac1f..f56e2c3ed33 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -456,12 +456,12 @@ def rename_old_gas_to_mbus( device_id=mbus_device_id, ) except ValueError: - LOGGER.warning( + LOGGER.debug( "Skip migration of %s because it already exists", entity.entity_id, ) else: - LOGGER.info( + LOGGER.debug( "Migrated entity %s from unique id %s to %s", entity.entity_id, entity.unique_id, From d7932031632d3f2bb6935a1605403a15d825b5c6 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Nov 2023 18:31:27 -0600 Subject: [PATCH 269/643] Skip TTS when text is empty (#104741) Co-authored-by: Paulus Schoutsen --- .../components/assist_pipeline/pipeline.py | 59 ++++++++------- .../components/esphome/voice_assistant.py | 22 +++--- homeassistant/components/voip/voip.py | 15 ++-- .../snapshots/test_websocket.ambr | 27 +++++++ .../assist_pipeline/test_websocket.py | 51 +++++++++++++ .../esphome/test_voice_assistant.py | 22 ++++++ tests/components/voip/test_voip.py | 72 +++++++++++++++++++ 7 files changed, 225 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 1eb32a9dc3f..4f2a9a8d99b 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1024,39 +1024,38 @@ class PipelineRun: ) ) - try: - # Synthesize audio and get URL - tts_media_id = tts_generate_media_source_id( - self.hass, - tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, - ) - except Exception as src_error: - _LOGGER.exception("Unexpected error during text-to-speech") - raise TextToSpeechError( - code="tts-failed", - message="Unexpected error during text-to-speech", - ) from src_error + if tts_input := tts_input.strip(): + try: + # Synthesize audio and get URL + tts_media_id = tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.tts_engine, + language=self.pipeline.tts_language, + options=self.tts_options, + ) + tts_media = await media_source.async_resolve_media( + self.hass, + tts_media_id, + None, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during text-to-speech") + raise TextToSpeechError( + code="tts-failed", + message="Unexpected error during text-to-speech", + ) from src_error - _LOGGER.debug("TTS result %s", tts_media) + _LOGGER.debug("TTS result %s", tts_media) + tts_output = { + "media_id": tts_media_id, + **asdict(tts_media), + } + else: + tts_output = {} self.process_event( - PipelineEvent( - PipelineEventType.TTS_END, - { - "tts_output": { - "media_id": tts_media_id, - **asdict(tts_media), - } - }, - ) + PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) ) return tts_media.url diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 68ed98aa789..de6b521d980 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -186,16 +186,22 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): data_to_send = {"text": event.data["tts_input"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: assert event.data is not None - path = event.data["tts_output"]["url"] - url = async_process_play_media_url(self.hass, path) - data_to_send = {"url": url} + tts_output = event.data["tts_output"] + if tts_output: + path = tts_output["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} - if self.device_info.voice_assistant_version >= 2: - media_id = event.data["tts_output"]["media_id"] - self._tts_task = self.hass.async_create_background_task( - self._send_tts(media_id), "esphome_voice_assistant_tts" - ) + if self.device_info.voice_assistant_version >= 2: + media_id = tts_output["media_id"] + self._tts_task = self.hass.async_create_background_task( + self._send_tts(media_id), "esphome_voice_assistant_tts" + ) + else: + self._tts_done.set() else: + # Empty TTS response + data_to_send = {} self._tts_done.set() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: assert event.data is not None diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 74bc94e7dc5..11f70c631f1 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -389,11 +389,16 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self._conversation_id = event.data["intent_output"]["conversation_id"] elif event.type == PipelineEventType.TTS_END: # Send TTS audio to caller over RTP - media_id = event.data["tts_output"]["media_id"] - self.hass.async_create_background_task( - self._send_tts(media_id), - "voip_pipeline_tts", - ) + tts_output = event.data["tts_output"] + if tts_output: + media_id = tts_output["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), + "voip_pipeline_tts", + ) + else: + # Empty TTS response + self._tts_done.set() elif event.type == PipelineEventType.ERROR: # Play error tone instead of wait for TTS self._pipeline_error = True diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 1f625528806..072b1ff730a 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -650,6 +650,33 @@ 'message': 'Timeout running pipeline', }) # --- +# name: test_pipeline_empty_tts_output + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': None, + 'timeout': 300, + }), + }) +# --- +# name: test_pipeline_empty_tts_output.1 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': '', + 'voice': 'james_earl_jones', + }) +# --- +# name: test_pipeline_empty_tts_output.2 + dict({ + 'tts_output': dict({ + }), + }) +# --- +# name: test_pipeline_empty_tts_output.3 + None +# --- # name: test_stt_provider_missing dict({ 'language': 'en', diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 931b31dd77b..0e2a3ad538c 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2452,3 +2452,54 @@ async def test_device_capture_queue_full( assert msg["event"] == snapshot assert msg["event"]["type"] == "end" assert msg["event"]["overflow"] + + +async def test_pipeline_empty_tts_output( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with a empty text-to-speech text.""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": { + "text": "", + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + assert not msg["event"]["data"]["tts_output"] + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index ca74c99f0cd..38a33bfdec2 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -337,6 +337,28 @@ async def test_send_tts_called( mock_send_tts.assert_called_with(_TEST_MEDIA_ID) +async def test_send_tts_not_called_when_empty( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server with a v1/v2 device doesn't call _send_tts when the output is empty.""" + with patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts" + ) as mock_send_tts: + voice_assistant_udp_server_v1._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + async def test_send_tts( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 692896c6dfa..dbb848f3b9d 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -528,3 +528,75 @@ async def test_tts_wrong_wav_format( # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(1): await done.wait() + + +async def test_empty_tts_output( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will not stream when output is empty.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Empty TTS output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {}}, + ) + ) + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.PipelineRtpDatagramProtocol._send_tts", + ) as mock_send_tts: + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to finish + async with asyncio.timeout(1): + await rtp_protocol._tts_done.wait() + + mock_send_tts.assert_not_called() From b957c4e3ee4f022742fdf7066741fdd0137bebf8 Mon Sep 17 00:00:00 2001 From: Daniel Gangl <31815106+killer0071234@users.noreply.github.com> Date: Thu, 30 Nov 2023 01:31:39 +0100 Subject: [PATCH 270/643] Bump zamg to 0.3.3 (#104756) --- homeassistant/components/zamg/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index df17672231e..f83e38002b8 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", "iot_class": "cloud_polling", - "requirements": ["zamg==0.3.0"] + "requirements": ["zamg==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index d19df8c1c9a..de84d8fb420 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2807,7 +2807,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5bf8f26c874..f2241b1c5d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2105,7 +2105,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zeroconf zeroconf==0.127.0 From ec647677e98a8b627481c8b6534c51c193203368 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 30 Nov 2023 01:38:33 +0100 Subject: [PATCH 271/643] Update initial translation for ViCare water heater entity (#104696) --- homeassistant/components/vicare/strings.json | 4 ++-- homeassistant/components/vicare/water_heater.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index e9ee272edd8..47ee60b2ea8 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -283,8 +283,8 @@ } }, "water_heater": { - "water": { - "name": "Water" + "domestic_hot_water": { + "name": "Domestic hot water" } } }, diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 036ced5ee55..66a90ca065b 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -64,13 +64,13 @@ def _build_entities( api: PyViCareDevice, device_config: PyViCareDeviceConfig, ) -> list[ViCareWater]: - """Create ViCare water entities for a device.""" + """Create ViCare domestic hot water entities for a device.""" return [ ViCareWater( api, circuit, device_config, - "water", + "domestic_hot_water", ) for circuit in get_circuits(api) ] @@ -81,7 +81,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the ViCare climate platform.""" + """Set up the ViCare water heater platform.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] From 9fa163c1076285b97988cb924110bf1b6ea1e842 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 30 Nov 2023 01:50:37 +0100 Subject: [PATCH 272/643] Reolink cleanup when CAM disconnected from NVR (#103888) Co-authored-by: Franck Nijhof --- homeassistant/components/reolink/__init__.py | 56 +++++++++++++++++ tests/components/reolink/conftest.py | 6 +- .../reolink/snapshots/test_diagnostics.ambr | 2 +- tests/components/reolink/test_init.py | 61 ++++++++++++++++++- 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 46761beae00..eca5a5aa853 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -148,6 +149,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b firmware_coordinator=firmware_coordinator, ) + cleanup_disconnected_cams(hass, config_entry.entry_id, host) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( @@ -175,3 +178,56 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok + + +def cleanup_disconnected_cams( + hass: HomeAssistant, config_entry_id: str, host: ReolinkHost +) -> None: + """Clean-up disconnected camera channels or channels where a different model camera is connected.""" + if not host.api.is_nvr: + return + + device_reg = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) + for device in devices: + device_id = [ + dev_id[1].split("_ch") + for dev_id in device.identifiers + if dev_id[0] == DOMAIN + ][0] + + if len(device_id) < 2: + # Do not consider the NVR itself + continue + + ch = int(device_id[1]) + ch_model = host.api.camera_model(ch) + remove = False + if ch not in host.api.channels: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, since no camera is connected to NVR channel %s anymore", + device.name, + ch, + ) + if ch_model not in [device.model, "Unknown"]: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, since the camera model connected to channel %s changed from %s to %s", + device.name, + ch, + device.model, + ch_model, + ) + if not remove: + continue + + # clean entity and device registry + entity_reg = er.async_get(hass) + entities = er.async_entries_for_device( + entity_reg, device.id, include_disabled_entities=True + ) + for entity in entities: + entity_reg.async_remove(entity.entity_id) + + device_reg.async_remove_device(device.id) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 75d2dc0c661..464d4120c65 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -25,6 +25,8 @@ TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" TEST_NVR_NAME2 = "test2_reolink_name" TEST_USE_HTTPS = True +TEST_HOST_MODEL = "RLN8-410" +TEST_CAM_MODEL = "RLC-123" @pytest.fixture @@ -70,8 +72,8 @@ def reolink_connect_class( host_mock.hardware_version = "IPC_00000" host_mock.sw_version = "v1.0.0.0.0.0000" host_mock.manufacturer = "Reolink" - host_mock.model = "RLC-123" - host_mock.camera_model.return_value = "RLC-123" + host_mock.model = TEST_HOST_MODEL + host_mock.camera_model.return_value = TEST_CAM_MODEL host_mock.camera_name.return_value = TEST_NVR_NAME host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.session_active = True diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 604a9364320..9f70673695c 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -41,7 +41,7 @@ 'event connection': 'Fast polling', 'firmware version': 'v1.0.0.0.0.0000', 'hardware version': 'IPC_00000', - 'model': 'RLC-123', + 'model': 'RLN8-410', 'stream channels': list([ 0, ]), diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index e2bd622bb43..6a9a8b957db 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -11,11 +11,15 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -102,6 +106,7 @@ async def test_entry_reloading( reolink_connect: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" + reolink_connect.is_nvr = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -115,6 +120,58 @@ async def test_entry_reloading( assert config_entry.title == "New Name" +@pytest.mark.parametrize( + ("attr", "value", "expected_models"), + [ + ( + None, + None, + [TEST_HOST_MODEL, TEST_CAM_MODEL], + ), + ("channels", [], [TEST_HOST_MODEL]), + ( + "camera_model", + Mock(return_value="RLC-567"), + [TEST_HOST_MODEL, "RLC-567"], + ), + ], +) +async def test_cleanup_disconnected_cams( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + attr: str | None, + value: Any, + expected_models: list[str], +) -> None: + """Test device and entity registry are cleaned up when camera is disconnected from NVR.""" + reolink_connect.channels = [0] + # setup CH 0 and NVR switch entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted([TEST_HOST_MODEL, TEST_CAM_MODEL]) + + # reload integration after 'disconnecting' a camera. + if attr is not None: + setattr(reolink_connect, attr, value) + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_reload(config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted(expected_models) + + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: From c72e4e8b5cd6c5872d6190a4f8040429b31c2804 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 21:11:38 -0800 Subject: [PATCH 273/643] Fix bug in rainbird device ids that are int serial numbers (#104768) --- homeassistant/components/rainbird/__init__.py | 2 +- tests/components/rainbird/test_init.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index c149c993acb..e5731dc08fe 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -183,7 +183,7 @@ def _async_fix_device_id( device_entry_map = {} migrations = {} for device_entry in device_entries: - unique_id = next(iter(device_entry.identifiers))[1] + unique_id = str(next(iter(device_entry.identifiers))[1]) device_entry_map[unique_id] = device_entry if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: migrations[unique_id] = f"{mac_address}{suffix}" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 7048e1d63f4..00cbefc6556 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -239,6 +239,14 @@ async def test_fix_unique_id_duplicate( f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", f"{MAC_ADDRESS_UNIQUE_ID}-1", ), + ( + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + ), ("0", 0, "0", "0", MAC_ADDRESS_UNIQUE_ID, MAC_ADDRESS_UNIQUE_ID), ( "0", @@ -268,6 +276,7 @@ async def test_fix_unique_id_duplicate( ids=( "serial-number", "serial-number-with-suffix", + "serial-number-int", "zero-serial", "zero-serial-suffix", "new-format", From 64a6a6a7786f15583135d7f3b88ab4734d642883 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 22:01:57 -0800 Subject: [PATCH 274/643] Add due date and description fields to Todoist To-do entity (#104655) * Add Todoist Due date and description fields * Update entity features with new names * Make items into walrus * Update due_datetime field * Add additional tests for adding new fields to items * Fix call args in todoist test --- homeassistant/components/todoist/todo.py | 41 ++++- tests/components/todoist/conftest.py | 5 +- tests/components/todoist/test_todo.py | 211 +++++++++++++++++++++-- 3 files changed, 236 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index c0d3ec6e2ce..64e83b8cc6e 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -1,7 +1,8 @@ """A todo platform for Todoist.""" import asyncio -from typing import cast +import datetime +from typing import Any, cast from homeassistant.components.todo import ( TodoItem, @@ -13,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import TodoistCoordinator @@ -30,6 +32,24 @@ async def async_setup_entry( ) +def _task_api_data(item: TodoItem) -> dict[str, Any]: + """Convert a TodoItem to the set of add or update arguments.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["content"] = summary + if due := item.due: + if isinstance(due, datetime.datetime): + item_data["due"] = { + "date": due.date().isoformat(), + "datetime": due.isoformat(), + } + else: + item_data["due"] = {"date": due.isoformat()} + if description := item.description: + item_data["description"] = description + return item_data + + class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity): """A Todoist TodoListEntity.""" @@ -37,6 +57,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -66,11 +89,21 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit status = TodoItemStatus.COMPLETED else: status = TodoItemStatus.NEEDS_ACTION + due: datetime.date | datetime.datetime | None = None + if task_due := task.due: + if task_due.datetime: + due = dt_util.as_local( + datetime.datetime.fromisoformat(task_due.datetime) + ) + elif task_due.date: + due = datetime.date.fromisoformat(task_due.date) items.append( TodoItem( summary=task.content, uid=task.id, status=status, + due=due, + description=task.description or None, # Don't use empty string ) ) self._attr_todo_items = items @@ -81,7 +114,7 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit if item.status != TodoItemStatus.NEEDS_ACTION: raise ValueError("Only active tasks may be created.") await self.coordinator.api.add_task( - content=item.summary or "", + **_task_api_data(item), project_id=self._project_id, ) await self.coordinator.async_refresh() @@ -89,8 +122,8 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit async def async_update_todo_item(self, item: TodoItem) -> None: """Update a To-do item.""" uid: str = cast(str, item.uid) - if item.summary: - await self.coordinator.api.update_task(task_id=uid, content=item.summary) + if update_data := _task_api_data(item): + await self.coordinator.api.update_task(task_id=uid, **update_data) if item.status is not None: if item.status == TodoItemStatus.COMPLETED: await self.coordinator.api.close_task(task_id=uid) diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 28f22e1061a..4e4d41b6914 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -45,6 +45,7 @@ def make_api_task( is_completed: bool = False, due: Due | None = None, project_id: str | None = None, + description: str | None = None, ) -> Task: """Mock a todoist Task instance.""" return Task( @@ -55,8 +56,8 @@ def make_api_task( content=content or SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", - description="A task", - due=due or Due(is_recurring=False, date=TODAY, string="today"), + description=description, + due=due, id=id or "1", labels=["Label1"], order=1, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index fb6f707be47..aa00e2c2ff4 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -1,7 +1,9 @@ """Unit tests for the Todoist todo platform.""" +from typing import Any from unittest.mock import AsyncMock import pytest +from todoist_api_python.models import Due, Task from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import Platform @@ -19,6 +21,12 @@ def platforms() -> list[Platform]: return [Platform.TODO] +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + @pytest.mark.parametrize( ("tasks", "expected_state"), [ @@ -57,11 +65,91 @@ async def test_todo_item_state( assert state.state == expected_state -@pytest.mark.parametrize(("tasks"), [[]]) +@pytest.mark.parametrize( + ("tasks", "item_data", "tasks_after_update", "add_kwargs", "expected_item"), + [ + ( + [], + {}, + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"content": "Soda"}, + {"uid": "task-id-1", "summary": "Soda", "status": "needs_action"}, + ), + ( + [], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["summary", "due_date", "due_datetime", "description"], +) async def test_add_todo_list_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + item_data: dict[str, Any], + tasks_after_update: list[Task], + add_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for adding a To-do Item.""" @@ -71,28 +159,35 @@ async def test_add_todo_list_item( api.add_task = AsyncMock() # Fake API response when state is refreshed after create - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=False) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.name"}, blocking=True, ) args = api.add_task.call_args assert args - assert args.kwargs.get("content") == "Soda" - assert args.kwargs.get("project_id") == PROJECT_ID + assert args.kwargs == {"project_id": PROJECT_ID, "content": "Soda", **add_kwargs} # Verify state is refreshed state = hass.states.get("todo.name") assert state assert state.state == "1" + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} + @pytest.mark.parametrize( ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] @@ -158,12 +253,91 @@ async def test_update_todo_item_status( @pytest.mark.parametrize( - ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] + ("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"), + [ + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"rename": "Milk"}, + [make_api_task(id="task-id-1", content="Milk", is_completed=False)], + {"task_id": "task-id-1", "content": "Milk"}, + {"uid": "task-id-1", "summary": "Milk", "status": "needs_action"}, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"task_id": "task-id-1", "due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "task_id": "task-id-1", + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"task_id": "task-id-1", "description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["rename", "due_date", "due_datetime", "description"], ) -async def test_update_todo_item_summary( +async def test_update_todo_items( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + update_data: dict[str, Any], + tasks_after_update: list[Task], + update_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for updating a To-do Item that changes the summary.""" @@ -174,22 +348,29 @@ async def test_update_todo_item_summary( api.update_task = AsyncMock() # Fake API response when state is refreshed after close - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=True) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "task-id-1", "rename": "Milk"}, + {"item": "task-id-1", **update_data}, target={"entity_id": "todo.name"}, blocking=True, ) assert api.update_task.called args = api.update_task.call_args assert args - assert args.kwargs.get("task_id") == "task-id-1" - assert args.kwargs.get("content") == "Milk" + assert args.kwargs == update_kwargs + + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} @pytest.mark.parametrize( From 2f0846bd878ef5f53841ac45bb476632a58e8b5a Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:12:39 +0100 Subject: [PATCH 275/643] Axis: add host and user name field description (#104693) --- homeassistant/components/axis/strings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 47a25b542a7..8c302dba201 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -3,12 +3,16 @@ "flow_title": "{name} ({host})", "step": { "user": { - "title": "Set up Axis device", + "description": "Set up an Axis device", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Axis device.", + "username": "The user name you set up on your Axis device. It is recommended to create a user specifically for Home Assistant." } } }, From 6ac9a1d1f276a76ce1293e0dd4c4657190a30fd8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Nov 2023 08:22:16 +0100 Subject: [PATCH 276/643] Migrate GDACS to has entity name (#104733) --- homeassistant/components/gdacs/sensor.py | 10 +++++++++- tests/components/gdacs/test_sensor.py | 8 ++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 5d5589c54d6..8a0a0113ced 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -7,6 +7,7 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -44,12 +45,14 @@ class GdacsSensor(SensorEntity): _attr_should_poll = False _attr_icon = DEFAULT_ICON _attr_native_unit_of_measurement = DEFAULT_UNIT_OF_MEASUREMENT + _attr_has_entity_name = True + _attr_name = None def __init__(self, config_entry: ConfigEntry, manager) -> None: """Initialize entity.""" + assert config_entry.unique_id self._config_entry_id = config_entry.entry_id self._attr_unique_id = config_entry.unique_id - self._attr_name = f"GDACS ({config_entry.title})" self._manager = manager self._status = None self._last_update = None @@ -60,6 +63,11 @@ class GdacsSensor(SensorEntity): self._updated = None self._removed = None self._remove_signal_status: Callable[[], None] | None = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.unique_id)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="GDACS", + ) async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index da318b1a94d..670d3efce51 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -72,10 +72,10 @@ async def test_setup(hass: HomeAssistant) -> None: == 4 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") assert state is not None assert int(state.state) == 3 - assert state.name == "GDACS (32.87336, -117.22743)" + assert state.name == "32.87336, -117.22743" attributes = state.attributes assert attributes[ATTR_STATUS] == "OK" assert attributes[ATTR_CREATED] == 3 @@ -96,7 +96,7 @@ async def test_setup(hass: HomeAssistant) -> None: == 4 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") attributes = state.attributes assert attributes[ATTR_CREATED] == 1 assert attributes[ATTR_UPDATED] == 2 @@ -125,6 +125,6 @@ async def test_setup(hass: HomeAssistant) -> None: == 1 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") attributes = state.attributes assert attributes[ATTR_REMOVED] == 3 From 8bc1f9d03d401f8ac210ff95b2ef14e68b193466 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:47:17 +0100 Subject: [PATCH 277/643] Comelit, Coolmaster: add host field description (#104771) --- homeassistant/components/comelit/strings.json | 3 +++ homeassistant/components/coolmaster/strings.json | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 730674e913a..73c2c7d00c6 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "host": "The hostname or IP address of your Comelit device." } } }, diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index 7baa6444c1d..17deab306df 100644 --- a/homeassistant/components/coolmaster/strings.json +++ b/homeassistant/components/coolmaster/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up your CoolMasterNet connection details.", + "description": "Set up your CoolMasterNet connection details.", "data": { "host": "[%key:common::config_flow::data::host%]", "off": "Can be turned off", @@ -12,6 +12,9 @@ "dry": "Support dry mode", "fan_only": "Support fan only mode", "swing_support": "Control swing mode" + }, + "data_description": { + "host": "The hostname or IP address of your CoolMasterNet device." } } }, From 56350d1c0a756f05ce633bff45118f6c29e2e9d1 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:47:37 +0100 Subject: [PATCH 278/643] Broadlink, BSB-Lan: add host field description (#104770) --- homeassistant/components/broadlink/strings.json | 5 ++++- homeassistant/components/bsblan/strings.json | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index 87567bcb7b1..335984d1ebe 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -3,10 +3,13 @@ "flow_title": "{name} ({model} at {host})", "step": { "user": { - "title": "Connect to the device", + "description": "Connect to the device", "data": { "host": "[%key:common::config_flow::data::host%]", "timeout": "Timeout" + }, + "data_description": { + "host": "The hostname or IP address of your Broadlink device." } }, "auth": { diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 0693f3fb8ea..689d1f893d3 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -11,6 +11,9 @@ "passkey": "Passkey string", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your BSB-Lan device." } } }, From da93daaa7542cbfb9800433575f0b870c27f81d4 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 09:43:42 +0100 Subject: [PATCH 279/643] Dremel to Duotecno: add host field description (#104776) Co-authored-by: Franck Nijhof --- homeassistant/components/dremel_3d_printer/strings.json | 3 +++ homeassistant/components/dunehd/strings.json | 3 +++ homeassistant/components/duotecno/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json index 0016b8f2bca..9f6870b57f6 100644 --- a/homeassistant/components/dremel_3d_printer/strings.json +++ b/homeassistant/components/dremel_3d_printer/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dremel 3D printer." } } }, diff --git a/homeassistant/components/dunehd/strings.json b/homeassistant/components/dunehd/strings.json index f7e12b39f16..7d60a720a98 100644 --- a/homeassistant/components/dunehd/strings.json +++ b/homeassistant/components/dunehd/strings.json @@ -5,6 +5,9 @@ "description": "Ensure that your player is turned on.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dune HD device." } } }, diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index 93a545d31dc..a5585c3dd2c 100644 --- a/homeassistant/components/duotecno/strings.json +++ b/homeassistant/components/duotecno/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Duotecno device." } } }, From dd00357e9cf1d50ba29ea9000824b5b45dc3ca1b Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 09:44:18 +0100 Subject: [PATCH 280/643] Ecoforest to Emonitor: add host field description (#104778) --- homeassistant/components/ecoforest/strings.json | 3 +++ homeassistant/components/elgato/strings.json | 3 +++ homeassistant/components/emonitor/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index d1767be5cda..1094e10ada3 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ecoforest device." } } }, diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index e6b16215793..6e1031c8ddf 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Elgato device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/emonitor/strings.json b/homeassistant/components/emonitor/strings.json index 675db107935..08ffe030890 100644 --- a/homeassistant/components/emonitor/strings.json +++ b/homeassistant/components/emonitor/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your SiteSage Emonitor device." } }, "confirm": { From 69af4c8603c188a860dfb8d993eaa9c1d8657714 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 30 Nov 2023 11:26:33 +0100 Subject: [PATCH 281/643] Add common reolink entity description (#104142) Co-authored-by: Robert Resch --- .../components/reolink/binary_sensor.py | 14 +++---- homeassistant/components/reolink/button.py | 24 ++++++------ homeassistant/components/reolink/camera.py | 11 ++---- homeassistant/components/reolink/entity.py | 30 ++++++++++++++- homeassistant/components/reolink/light.py | 22 +++++------ homeassistant/components/reolink/number.py | 13 +++---- homeassistant/components/reolink/select.py | 14 +++---- homeassistant/components/reolink/sensor.py | 37 ++++++++----------- homeassistant/components/reolink/siren.py | 16 +++----- homeassistant/components/reolink/switch.py | 27 ++++++++------ 10 files changed, 109 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index e2e8e6b24f9..226b81b1c74 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -25,16 +25,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass(kw_only=True) -class ReolinkBinarySensorEntityDescription(BinarySensorEntityDescription): +class ReolinkBinarySensorEntityDescription( + BinarySensorEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes binary sensor entities.""" icon_off: str = "mdi:motion-sensor-off" icon: str = "mdi:motion-sensor" - supported: Callable[[Host, int], bool] = lambda host, ch: True value: Callable[[Host, int], bool] @@ -128,8 +130,8 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt entity_description: ReolinkBinarySensorEntityDescription, ) -> None: """Initialize Reolink binary sensor.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS: if entity_description.translation_key is not None: @@ -138,10 +140,6 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt key = entity_description.key self._attr_translation_key = f"{key}_lens_{self._channel}" - self._attr_unique_id = ( - f"{self._host.unique_id}_{self._channel}_{entity_description.key}" - ) - @property def icon(self) -> str | None: """Icon of the sensor.""" diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 6e9c9c2e386..88204d9a806 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -27,7 +27,12 @@ from homeassistant.helpers.entity_platform import ( from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) ATTR_SPEED = "speed" SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM @@ -36,21 +41,23 @@ SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM @dataclass(kw_only=True) class ReolinkButtonEntityDescription( ButtonEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes button entities for a camera channel.""" enabled_default: Callable[[Host, int], bool] | None = None method: Callable[[Host, int], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True ptz_cmd: str | None = None @dataclass(kw_only=True) -class ReolinkHostButtonEntityDescription(ButtonEntityDescription): +class ReolinkHostButtonEntityDescription( + ButtonEntityDescription, + ReolinkHostEntityDescription, +): """A class that describes button entities for the host.""" method: Callable[[Host], Any] - supported: Callable[[Host], bool] = lambda api: True BUTTON_ENTITIES = ( @@ -195,12 +202,9 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): entity_description: ReolinkButtonEntityDescription, ) -> None: """Initialize Reolink button entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) if entity_description.enabled_default is not None: self._attr_entity_registry_enabled_default = ( entity_description.enabled_default(self._host.api, self._channel) @@ -241,10 +245,8 @@ class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): entity_description: ReolinkHostButtonEntityDescription, ) -> None: """Initialize Reolink button entity.""" - super().__init__(reolink_data) self.entity_description = entity_description - - self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + super().__init__(reolink_data) async def async_press(self) -> None: """Execute the button action.""" diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index ea9b84cd53f..2ad8105c66c 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,11 +1,10 @@ """Component providing support for Reolink IP cameras.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass import logging -from reolink_aio.api import DUAL_LENS_MODELS, Host +from reolink_aio.api import DUAL_LENS_MODELS from reolink_aio.exceptions import ReolinkError from homeassistant.components.camera import ( @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription _LOGGER = logging.getLogger(__name__) @@ -28,11 +27,11 @@ _LOGGER = logging.getLogger(__name__) @dataclass(kw_only=True) class ReolinkCameraEntityDescription( CameraEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes camera entities for a camera channel.""" stream: str - supported: Callable[[Host, int], bool] = lambda api, ch: True CAMERA_ENTITIES = ( @@ -135,10 +134,6 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): f"{entity_description.translation_key}_lens_{self._channel}" ) - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) - async def stream_source(self) -> str | None: """Return the source of the stream.""" return await self._host.api.get_stream_source( diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 5c874fb7ff9..584b380f391 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,11 +1,14 @@ """Reolink parent entity class.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import TypeVar -from reolink_aio.api import DUAL_LENS_MODELS +from reolink_aio.api import DUAL_LENS_MODELS, Host from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -17,8 +20,22 @@ from .const import DOMAIN _T = TypeVar("_T") +@dataclass(kw_only=True) +class ReolinkChannelEntityDescription(EntityDescription): + """A class that describes entities for a camera channel.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +@dataclass(kw_only=True) +class ReolinkHostEntityDescription(EntityDescription): + """A class that describes host entities.""" + + supported: Callable[[Host], bool] = lambda api: True + + class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]): - """Parent class fo Reolink entities.""" + """Parent class for Reolink entities.""" _attr_has_entity_name = True @@ -59,14 +76,20 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): basically a NVR with a single channel that has the camera connected to that channel. """ + entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription + def __init__(self, reolink_data: ReolinkData) -> None: """Initialize ReolinkHostCoordinatorEntity.""" super().__init__(reolink_data, reolink_data.device_coordinator) + self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" + class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" + entity_description: ReolinkChannelEntityDescription + def __init__( self, reolink_data: ReolinkData, @@ -76,6 +99,9 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): super().__init__(reolink_data) self._channel = channel + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{self.entity_description.key}" + ) dev_ch = channel if self._host.api.model in DUAL_LENS_MODELS: diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index f1aa0cb9ee2..b2d0402b1b9 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -22,17 +22,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass(kw_only=True) -class ReolinkLightEntityDescription(LightEntityDescription): +class ReolinkLightEntityDescription( + LightEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes light entities.""" get_brightness_fn: Callable[[Host, int], int | None] | None = None is_on_fn: Callable[[Host, int], bool] set_brightness_fn: Callable[[Host, int, int], Any] | None = None - supported_fn: Callable[[Host, int], bool] = lambda api, ch: True turn_on_off_fn: Callable[[Host, int, bool], Any] @@ -41,7 +43,7 @@ LIGHT_ENTITIES = ( key="floodlight", translation_key="floodlight", icon="mdi:spotlight-beam", - supported_fn=lambda api, ch: api.supported(ch, "floodLight"), + supported=lambda api, ch: api.supported(ch, "floodLight"), is_on_fn=lambda api, ch: api.whiteled_state(ch), turn_on_off_fn=lambda api, ch, value: api.set_whiteled(ch, state=value), get_brightness_fn=lambda api, ch: api.whiteled_brightness(ch), @@ -52,7 +54,7 @@ LIGHT_ENTITIES = ( translation_key="ir_lights", icon="mdi:led-off", entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "ir_lights"), + supported=lambda api, ch: api.supported(ch, "ir_lights"), is_on_fn=lambda api, ch: api.ir_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_ir_lights(ch, value), ), @@ -61,7 +63,7 @@ LIGHT_ENTITIES = ( translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "power_led"), + supported=lambda api, ch: api.supported(ch, "power_led"), is_on_fn=lambda api, ch: api.status_led_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_status_led(ch, value), ), @@ -80,7 +82,7 @@ async def async_setup_entry( ReolinkLightEntity(reolink_data, channel, entity_description) for entity_description in LIGHT_ENTITIES for channel in reolink_data.host.api.channels - if entity_description.supported_fn(reolink_data.host.api, channel) + if entity_description.supported(reolink_data.host.api, channel) ) @@ -96,12 +98,8 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): entity_description: ReolinkLightEntityDescription, ) -> None: """Initialize Reolink light entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) if entity_description.set_brightness_fn is None: self._attr_supported_color_modes = {ColorMode.ONOFF} diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 1780465850a..6a89eabba2b 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -21,18 +21,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass(kw_only=True) -class ReolinkNumberEntityDescription(NumberEntityDescription): +class ReolinkNumberEntityDescription( + NumberEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes number entities.""" get_max_value: Callable[[Host, int], float] | None = None get_min_value: Callable[[Host, int], float] | None = None method: Callable[[Host, int, float], Any] mode: NumberMode = NumberMode.AUTO - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], float | None] @@ -378,8 +380,8 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): entity_description: ReolinkNumberEntityDescription, ) -> None: """Initialize Reolink number entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) if entity_description.get_min_value is not None: self._attr_native_min_value = entity_description.get_min_value( @@ -390,9 +392,6 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): self._host.api, channel ) self._attr_mode = entity_description.mode - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) @property def native_value(self) -> float | None: diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 566dbc92fbe..3d75b08b5d1 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -24,18 +24,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription _LOGGER = logging.getLogger(__name__) @dataclass(kw_only=True) -class ReolinkSelectEntityDescription(SelectEntityDescription): +class ReolinkSelectEntityDescription( + SelectEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes select entities.""" get_options: list[str] | Callable[[Host, int], list[str]] method: Callable[[Host, int, str], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], str] | None = None @@ -131,14 +133,10 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): entity_description: ReolinkSelectEntityDescription, ) -> None: """Initialize Reolink select entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) self._log_error = True - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) - if callable(entity_description.get_options): self._attr_options = entity_description.get_options(self._host.api, channel) else: diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 9a03f497944..3a5da97dc61 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -21,31 +21,32 @@ from homeassistant.helpers.typing import StateType from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) @dataclass(kw_only=True) -class ReolinkSensorEntityDescription(SensorEntityDescription): +class ReolinkSensorEntityDescription( + SensorEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes sensor entities for a camera channel.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], int] -@dataclass -class ReolinkHostSensorEntityDescriptionMixin: - """Mixin values for Reolink host sensor entities.""" - - value: Callable[[Host], int | None] - - -@dataclass +@dataclass(kw_only=True) class ReolinkHostSensorEntityDescription( - SensorEntityDescription, ReolinkHostSensorEntityDescriptionMixin + SensorEntityDescription, + ReolinkHostEntityDescription, ): """A class that describes host sensor entities.""" - supported: Callable[[Host], bool] = lambda api: True + value: Callable[[Host], int | None] SENSORS = ( @@ -110,12 +111,8 @@ class ReolinkSensorEntity(ReolinkChannelCoordinatorEntity, SensorEntity): entity_description: ReolinkSensorEntityDescription, ) -> None: """Initialize Reolink sensor.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) @property def native_value(self) -> StateType | date | datetime | Decimal: @@ -134,10 +131,8 @@ class ReolinkHostSensorEntity(ReolinkHostCoordinatorEntity, SensorEntity): entity_description: ReolinkHostSensorEntityDescription, ) -> None: """Initialize Reolink host sensor.""" - super().__init__(reolink_data) self.entity_description = entity_description - - self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + super().__init__(reolink_data) @property def native_value(self) -> StateType | date | datetime | Decimal: diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index f063b65e2b4..ec709f6ae3d 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -1,11 +1,9 @@ """Component providing support for Reolink siren entities.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass from typing import Any -from reolink_aio.api import Host from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.siren import ( @@ -22,15 +20,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass -class ReolinkSirenEntityDescription(SirenEntityDescription): +class ReolinkSirenEntityDescription( + SirenEntityDescription, ReolinkChannelEntityDescription +): """A class that describes siren entities.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True - SIREN_ENTITIES = ( ReolinkSirenEntityDescription( @@ -76,12 +74,8 @@ class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity): entity_description: ReolinkSirenEntityDescription, ) -> None: """Initialize Reolink siren entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the siren.""" diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index eb77b16478f..fbb8922188d 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -17,24 +17,33 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) @dataclass(kw_only=True) -class ReolinkSwitchEntityDescription(SwitchEntityDescription): +class ReolinkSwitchEntityDescription( + SwitchEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes switch entities.""" method: Callable[[Host, int, bool], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], bool] @dataclass(kw_only=True) -class ReolinkNVRSwitchEntityDescription(SwitchEntityDescription): +class ReolinkNVRSwitchEntityDescription( + SwitchEntityDescription, + ReolinkHostEntityDescription, +): """A class that describes NVR switch entities.""" method: Callable[[Host, bool], Any] - supported: Callable[[Host], bool] = lambda api: True value: Callable[[Host], bool] @@ -235,12 +244,8 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): entity_description: ReolinkSwitchEntityDescription, ) -> None: """Initialize Reolink switch entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) @property def is_on(self) -> bool: @@ -275,8 +280,8 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): entity_description: ReolinkNVRSwitchEntityDescription, ) -> None: """Initialize Reolink switch entity.""" - super().__init__(reolink_data) self.entity_description = entity_description + super().__init__(reolink_data) self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" From d1a2192e37274e04b3e778eea1a435e6b10261fc Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 11:59:20 +0100 Subject: [PATCH 282/643] FiveM to Foscam: add host field description (#104782) --- homeassistant/components/fivem/strings.json | 3 +++ homeassistant/components/flo/strings.json | 3 +++ homeassistant/components/foscam/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json index 2ffb401f8c0..abdef61fb28 100644 --- a/homeassistant/components/fivem/strings.json +++ b/homeassistant/components/fivem/strings.json @@ -6,6 +6,9 @@ "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your FiveM server." } } }, diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index 627f562be7e..3444911fbd4 100644 --- a/homeassistant/components/flo/strings.json +++ b/homeassistant/components/flo/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Flo device." } } }, diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 35964ee4546..de22006b274 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "rtsp_port": "RTSP port", "stream": "Stream" + }, + "data_description": { + "host": "The hostname or IP address of your Foscam camera." } } }, From 1ab9357840c799375badb1114051bdbb677f4d96 Mon Sep 17 00:00:00 2001 From: Sergiy Maysak Date: Thu, 30 Nov 2023 13:14:46 +0200 Subject: [PATCH 283/643] Fix wirelesstag unique_id to use uuid instead of tag_id (#104394) Co-authored-by: Robert Resch --- .../components/wirelesstag/__init__.py | 16 ++++++++++ .../components/wirelesstag/binary_sensor.py | 12 ++++--- .../components/wirelesstag/sensor.py | 18 +++++++---- .../components/wirelesstag/switch.py | 32 +++++++++++-------- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 06fbfa3621e..f95337dbaf4 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -5,6 +5,7 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from wirelesstagpy import WirelessTags from wirelesstagpy.exceptions import WirelessTagsException +from wirelesstagpy.sensortag import SensorTag from homeassistant.components import persistent_notification from homeassistant.const import ( @@ -17,6 +18,7 @@ from homeassistant.const import ( UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity @@ -126,6 +128,20 @@ class WirelessTagPlatform: self.api.start_monitoring(push_callback) +def async_migrate_unique_id(hass: HomeAssistant, tag: SensorTag, domain: str, key: str): + """Migrate old unique id to new one with use of tag's uuid.""" + registry = er.async_get(hass) + new_unique_id = f"{tag.uuid}_{key}" + + if registry.async_get_entity_id(domain, DOMAIN, new_unique_id): + return + + old_unique_id = f"{tag.tag_id}_{key}" + if entity_id := registry.async_get_entity_id(domain, DOMAIN, old_unique_id): + _LOGGER.debug("Updating unique id for %s %s", key, entity_id) + registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Wireless Sensor Tag component.""" conf = config[DOMAIN] diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 711c2987735..64a1097bcab 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON +from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -15,6 +15,7 @@ from . import ( DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_BINARY_EVENT_UPDATE, WirelessTagBaseSensor, + async_migrate_unique_id, ) # On means in range, Off means out of range @@ -72,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the platform for a WirelessTags.""" @@ -87,9 +88,10 @@ def setup_platform( allowed_sensor_types = tag.supported_binary_events_types for sensor_type in config[CONF_MONITORED_CONDITIONS]: if sensor_type in allowed_sensor_types: + async_migrate_unique_id(hass, tag, Platform.BINARY_SENSOR, sensor_type) sensors.append(WirelessTagBinarySensor(platform, tag, sensor_type)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): @@ -100,7 +102,7 @@ class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): super().__init__(api, tag) self._sensor_type = sensor_type self._name = f"{self._tag.name} {self.event.human_readable_name}" - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index fd9a7898f92..8ae20031723 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -12,14 +12,19 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_TAG_UPDATE, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + SIGNAL_TAG_UPDATE, + WirelessTagBaseSensor, + async_migrate_unique_id, +) _LOGGER = logging.getLogger(__name__) @@ -68,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" @@ -83,9 +88,10 @@ def setup_platform( if key not in tag.allowed_sensor_types: continue description = SENSOR_TYPES[key] + async_migrate_unique_id(hass, tag, Platform.SENSOR, description.key) sensors.append(WirelessTagSensor(platform, tag, description)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): @@ -100,7 +106,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): self._sensor_type = description.key self.entity_description = description self._name = self._tag.name - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" # I want to see entity_id as: # sensor.wirelesstag_bedroom_temperature diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index df0f72aca18..7f4008623b1 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -10,13 +10,17 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as WIRELESSTAG_DOMAIN, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + WirelessTagBaseSensor, + async_migrate_unique_id, +) SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -52,10 +56,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switches for a Wireless Sensor Tags.""" @@ -63,15 +67,17 @@ def setup_platform( tags = platform.load_tags() monitored_conditions = config[CONF_MONITORED_CONDITIONS] - entities = [ - WirelessTagSwitch(platform, tag, description) - for tag in tags.values() - for description in SWITCH_TYPES - if description.key in monitored_conditions - and description.key in tag.allowed_monitoring_types - ] + entities = [] + for tag in tags.values(): + for description in SWITCH_TYPES: + if ( + description.key in monitored_conditions + and description.key in tag.allowed_monitoring_types + ): + async_migrate_unique_id(hass, tag, Platform.SWITCH, description.key) + entities.append(WirelessTagSwitch(platform, tag, description)) - add_entities(entities, True) + async_add_entities(entities, True) class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): @@ -82,7 +88,7 @@ class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): super().__init__(api, tag) self.entity_description = description self._name = f"{self._tag.name} {description.name}" - self._attr_unique_id = f"{self.tag_id}_{description.key}" + self._attr_unique_id = f"{self._uuid}_{description.key}" def turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" From 68e883dc6347ac147671bc54641183aa3f8f1d47 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:50:58 +0100 Subject: [PATCH 284/643] Fix runtime error in CalDAV (#104800) --- homeassistant/components/caldav/api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index f9236049048..fa89d6acc38 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -11,7 +11,11 @@ async def async_get_calendars( hass: HomeAssistant, client: caldav.DAVClient, component: str ) -> list[caldav.Calendar]: """Get all calendars that support the specified component.""" - calendars = await hass.async_add_executor_job(client.principal().calendars) + + def _get_calendars() -> list[caldav.Calendar]: + return client.principal().calendars() + + calendars = await hass.async_add_executor_job(_get_calendars) components_results = await asyncio.gather( *[ hass.async_add_executor_job(calendar.get_supported_components) From fa7a74c61157186dfb46c515014aa3a6f2fe1b77 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 30 Nov 2023 13:54:37 +0100 Subject: [PATCH 285/643] Cleanup Reolink formatting (late review) (#104793) cleanup --- homeassistant/components/reolink/__init__.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index eca5a5aa853..7f8448d277d 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -183,7 +183,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> def cleanup_disconnected_cams( hass: HomeAssistant, config_entry_id: str, host: ReolinkHost ) -> None: - """Clean-up disconnected camera channels or channels where a different model camera is connected.""" + """Clean-up disconnected camera channels.""" if not host.api.is_nvr: return @@ -206,14 +206,16 @@ def cleanup_disconnected_cams( if ch not in host.api.channels: remove = True _LOGGER.debug( - "Removing Reolink device %s, since no camera is connected to NVR channel %s anymore", + "Removing Reolink device %s, " + "since no camera is connected to NVR channel %s anymore", device.name, ch, ) if ch_model not in [device.model, "Unknown"]: remove = True _LOGGER.debug( - "Removing Reolink device %s, since the camera model connected to channel %s changed from %s to %s", + "Removing Reolink device %s, " + "since the camera model connected to channel %s changed from %s to %s", device.name, ch, device.model, @@ -222,12 +224,5 @@ def cleanup_disconnected_cams( if not remove: continue - # clean entity and device registry - entity_reg = er.async_get(hass) - entities = er.async_entries_for_device( - entity_reg, device.id, include_disabled_entities=True - ) - for entity in entities: - entity_reg.async_remove(entity.entity_id) - + # clean device registry and associated entities device_reg.async_remove_device(device.id) From aa4382e09175e527fe97156963cc75bb4859a948 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Thu, 30 Nov 2023 08:22:17 -0500 Subject: [PATCH 286/643] Use .get for Fully Kiosk SSL settings in coordinator (#104801) --- homeassistant/components/fully_kiosk/coordinator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py index 17facb79dbb..203251351ae 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -19,13 +19,14 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize.""" + self.use_ssl = entry.data.get(CONF_SSL, False) self.fully = FullyKiosk( async_get_clientsession(hass), entry.data[CONF_HOST], DEFAULT_PORT, entry.data[CONF_PASSWORD], - use_ssl=entry.data[CONF_SSL], - verify_ssl=entry.data[CONF_VERIFY_SSL], + use_ssl=self.use_ssl, + verify_ssl=entry.data.get(CONF_VERIFY_SSL, False), ) super().__init__( hass, @@ -33,7 +34,6 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): name=entry.data[CONF_HOST], update_interval=UPDATE_INTERVAL, ) - self.use_ssl = entry.data[CONF_SSL] async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" From f59588b4139f24be149d03017c2a6611dad00abc Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 30 Nov 2023 15:41:58 +0100 Subject: [PATCH 287/643] Make the minimum number of samples used by the trend sensor configurable (#101102) * Make the minimum of samples configurable & raise issue when min_samples > max_samples * Wording * Remove issue creation and use a custom schema validator * Remove issue from strings.json * Add test for validator and fix error message --- .../components/trend/binary_sensor.py | 45 +++++++--- homeassistant/components/trend/const.py | 1 + tests/components/trend/test_binary_sensor.py | 87 +++++++++++++++++-- 3 files changed, 115 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 2d00f35202c..fa6ad8e5382 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -52,23 +52,39 @@ from .const import ( CONF_INVERT, CONF_MAX_SAMPLES, CONF_MIN_GRADIENT, + CONF_MIN_SAMPLES, CONF_SAMPLE_DURATION, DOMAIN, ) _LOGGER = logging.getLogger(__name__) -SENSOR_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_INVERT, default=False): cv.boolean, - vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, - vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), - vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, - } + +def _validate_min_max(data: dict[str, Any]) -> dict[str, Any]: + if ( + CONF_MIN_SAMPLES in data + and CONF_MAX_SAMPLES in data + and data[CONF_MAX_SAMPLES] < data[CONF_MIN_SAMPLES] + ): + raise vol.Invalid("min_samples must be smaller than or equal to max_samples") + return data + + +SENSOR_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, + vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, + vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), + vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, + vol.Optional(CONF_MIN_SAMPLES, default=2): cv.positive_int, + } + ), + _validate_min_max, ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -96,6 +112,7 @@ async def async_setup_platform( max_samples = device_config[CONF_MAX_SAMPLES] min_gradient = device_config[CONF_MIN_GRADIENT] sample_duration = device_config[CONF_SAMPLE_DURATION] + min_samples = device_config[CONF_MIN_SAMPLES] sensors.append( SensorTrend( @@ -109,8 +126,10 @@ async def async_setup_platform( max_samples, min_gradient, sample_duration, + min_samples, ) ) + if not sensors: _LOGGER.error("No sensors added") return @@ -137,6 +156,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): max_samples: int, min_gradient: float, sample_duration: int, + min_samples: int, ) -> None: """Initialize the sensor.""" self._hass = hass @@ -148,6 +168,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): self._invert = invert self._sample_duration = sample_duration self._min_gradient = min_gradient + self._min_samples = min_samples self.samples: deque = deque(maxlen=max_samples) @property @@ -210,7 +231,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): while self.samples and self.samples[0][0] < cutoff: self.samples.popleft() - if len(self.samples) < 2: + if len(self.samples) < self._min_samples: return # Calculate gradient of linear trend diff --git a/homeassistant/components/trend/const.py b/homeassistant/components/trend/const.py index 6787dc08445..3d82bfcc648 100644 --- a/homeassistant/components/trend/const.py +++ b/homeassistant/components/trend/const.py @@ -12,3 +12,4 @@ CONF_INVERT = "invert" CONF_MAX_SAMPLES = "max_samples" CONF_MIN_GRADIENT = "min_gradient" CONF_SAMPLE_DURATION = "sample_duration" +CONF_MIN_SAMPLES = "min_samples" diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index cccf1add61b..ddd980ae970 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -1,5 +1,6 @@ """The test for the Trend sensor platform.""" from datetime import timedelta +import logging from unittest.mock import patch import pytest @@ -68,6 +69,7 @@ class TestTrendBinarySensor: "sample_duration": 10000, "min_gradient": 1, "max_samples": 25, + "min_samples": 5, } }, } @@ -76,24 +78,35 @@ class TestTrendBinarySensor: self.hass.block_till_done() now = dt_util.utcnow() + + # add not enough states to trigger calculation for val in [10, 0, 20, 30]: with patch("homeassistant.util.dt.utcnow", return_value=now): self.hass.states.set("sensor.test_state", val) self.hass.block_till_done() now += timedelta(seconds=2) - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" + assert ( + self.hass.states.get("binary_sensor.test_trend_sensor").state == "unknown" + ) - # have to change state value, otherwise sample will lost + # add one more state to trigger gradient calculation + for val in [100]: + with patch("homeassistant.util.dt.utcnow", return_value=now): + self.hass.states.set("sensor.test_state", val) + self.hass.block_till_done() + now += timedelta(seconds=2) + + assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "on" + + # add more states to trigger a downtrend for val in [0, 30, 1, 0]: with patch("homeassistant.util.dt.utcnow", return_value=now): self.hass.states.set("sensor.test_state", val) self.hass.block_till_done() now += timedelta(seconds=2) - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" + assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "off" def test_down_using_trendline(self): """Test down trend using multiple samples and trendline calculation.""" @@ -434,10 +447,72 @@ async def test_restore_state( { "binary_sensor": { "platform": "trend", - "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, + "sensors": { + "test_trend_sensor": { + "entity_id": "sensor.test_state", + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + } + }, } }, ) await hass.async_block_till_done() + # restored sensor should match saved one assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state + + now = dt_util.utcnow() + + # add not enough samples to trigger calculation + for val in [10, 20, 30, 40]: + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + now += timedelta(seconds=2) + + # state should match restored state as no calculation happened + assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state + + # add more samples to trigger calculation + for val in [50, 60, 70, 80]: + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + now += timedelta(seconds=2) + + # sensor should detect an upwards trend and turn on + assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" + + +async def test_invalid_min_sample( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test if error is logged when min_sample is larger than max_samples.""" + with caplog.at_level(logging.ERROR): + assert await setup.async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": { + "test_trend_sensor": { + "entity_id": "sensor.test_state", + "max_samples": 25, + "min_samples": 30, + } + }, + } + }, + ) + await hass.async_block_till_done() + + record = caplog.records[0] + assert record.levelname == "ERROR" + assert ( + "Invalid config for 'binary_sensor.trend': min_samples must be smaller than or equal to max_samples" + in record.message + ) From 1c78848c432f7a5d1c5ee3390519879d15eec753 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:38:40 +0100 Subject: [PATCH 288/643] HLK to Hyperion: add host field description (#104789) Co-authored-by: Franck Nijhof --- homeassistant/components/hlk_sw16/strings.json | 3 +++ homeassistant/components/hue/strings.json | 6 ++++++ homeassistant/components/hyperion/strings.json | 3 +++ 3 files changed, 12 insertions(+) diff --git a/homeassistant/components/hlk_sw16/strings.json b/homeassistant/components/hlk_sw16/strings.json index d6e3212b4ea..ba74547e355 100644 --- a/homeassistant/components/hlk_sw16/strings.json +++ b/homeassistant/components/hlk_sw16/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hi-Link HLK-SW-16 device." } } }, diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 4022c61bc36..122cb489d26 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -5,12 +5,18 @@ "title": "Pick Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hue bridge." } }, "manual": { "title": "Manual configure a Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hue bridge." } }, "link": { diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index a2f8838e2ea..8d7e3751c4c 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hyperion server." } }, "auth": { From 25ebbda3a9bb8e54caefe7d645267c91bee93da3 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Thu, 30 Nov 2023 16:50:13 +0100 Subject: [PATCH 289/643] Add Flexit bacnet integration (#104275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joost Lekkerkerker Co-authored-by: Piotr Buliński Co-authored-by: Piotr Buliński --- .coveragerc | 3 + .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/flexit.json | 5 + .../components/flexit_bacnet/__init__.py | 43 +++++ .../components/flexit_bacnet/climate.py | 148 ++++++++++++++++++ .../components/flexit_bacnet/config_flow.py | 62 ++++++++ .../components/flexit_bacnet/const.py | 30 ++++ .../components/flexit_bacnet/manifest.json | 10 ++ .../components/flexit_bacnet/strings.json | 19 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 17 +- mypy.ini | 10 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/flexit_bacnet/__init__.py | 1 + tests/components/flexit_bacnet/conftest.py | 44 ++++++ .../flexit_bacnet/test_config_flow.py | 120 ++++++++++++++ 18 files changed, 519 insertions(+), 3 deletions(-) create mode 100644 homeassistant/brands/flexit.json create mode 100644 homeassistant/components/flexit_bacnet/__init__.py create mode 100644 homeassistant/components/flexit_bacnet/climate.py create mode 100644 homeassistant/components/flexit_bacnet/config_flow.py create mode 100644 homeassistant/components/flexit_bacnet/const.py create mode 100644 homeassistant/components/flexit_bacnet/manifest.json create mode 100644 homeassistant/components/flexit_bacnet/strings.json create mode 100644 tests/components/flexit_bacnet/__init__.py create mode 100644 tests/components/flexit_bacnet/conftest.py create mode 100644 tests/components/flexit_bacnet/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index f15d36918ec..29b48e439ea 100644 --- a/.coveragerc +++ b/.coveragerc @@ -404,6 +404,9 @@ omit = homeassistant/components/fjaraskupan/sensor.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py + homeassistant/components/flexit_bacnet/__init__.py + homeassistant/components/flexit_bacnet/const.py + homeassistant/components/flexit_bacnet/climate.py homeassistant/components/flic/binary_sensor.py homeassistant/components/flick_electric/__init__.py homeassistant/components/flick_electric/sensor.py diff --git a/.strict-typing b/.strict-typing index a4969bcc810..daa4a56dead 100644 --- a/.strict-typing +++ b/.strict-typing @@ -128,6 +128,7 @@ homeassistant.components.file_upload.* homeassistant.components.filesize.* homeassistant.components.filter.* homeassistant.components.fitbit.* +homeassistant.components.flexit_bacnet.* homeassistant.components.flux_led.* homeassistant.components.forecast_solar.* homeassistant.components.fritz.* diff --git a/CODEOWNERS b/CODEOWNERS index ec32f941d56..d41975259b5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -395,6 +395,8 @@ build.json @home-assistant/supervisor /tests/components/fivem/ @Sander0542 /homeassistant/components/fjaraskupan/ @elupus /tests/components/fjaraskupan/ @elupus +/homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski +/tests/components/flexit_bacnet/ @lellky @piotrbulinski /homeassistant/components/flick_electric/ @ZephireNZ /tests/components/flick_electric/ @ZephireNZ /homeassistant/components/flipr/ @cnico diff --git a/homeassistant/brands/flexit.json b/homeassistant/brands/flexit.json new file mode 100644 index 00000000000..4c61c5eeb07 --- /dev/null +++ b/homeassistant/brands/flexit.json @@ -0,0 +1,5 @@ +{ + "domain": "flexit", + "name": "Flexit", + "integrations": ["flexit", "flexit_bacnet"] +} diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000..c9a0b332d93 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -0,0 +1,43 @@ +"""The Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Flexit Nordic (BACnet) from a config entry.""" + + device = FlexitBACnet(entry.data[CONF_IP_ADDRESS], entry.data[CONF_DEVICE_ID]) + + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise ConfigEntryNotReady( + f"Timeout while connecting to {entry.data['address']}" + ) from exc + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py new file mode 100644 index 00000000000..28f4a6ae178 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -0,0 +1,148 @@ +"""The Flexit Nordic (BACnet) integration.""" +import asyncio.exceptions +from typing import Any + +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, + FlexitBACnet, +) +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + PRESET_TO_VENTILATION_MODE_MAP, + VENTILATION_TO_PRESET_MODE_MAP, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up the Flexit Nordic unit.""" + device = hass.data[DOMAIN][config_entry.entry_id] + + async_add_devices([FlexitClimateEntity(device)]) + + +class FlexitClimateEntity(ClimateEntity): + """Flexit air handling unit.""" + + _attr_name = None + + _attr_has_entity_name = True + + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.FAN_ONLY, + ] + + _attr_preset_modes = [ + PRESET_AWAY, + PRESET_HOME, + PRESET_BOOST, + ] + + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, device: FlexitBACnet) -> None: + """Initialize the unit.""" + self._device = device + self._attr_unique_id = device.serial_number + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, device.serial_number), + }, + name=device.device_name, + manufacturer="Flexit", + model="Nordic", + serial_number=device.serial_number, + ) + + async def async_update(self) -> None: + """Refresh unit state.""" + await self._device.update() + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + return self._device.room_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + return self._device.air_temp_setpoint_away + + return self._device.air_temp_setpoint_home + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + try: + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + await self._device.set_air_temp_setpoint_away(temperature) + else: + await self._device.set_air_temp_setpoint_home(temperature) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., home, away, temp. + + Requires ClimateEntityFeature.PRESET_MODE. + """ + return VENTILATION_TO_PRESET_MODE_MAP[self._device.ventilation_mode] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode] + + try: + await self._device.set_ventilation_mode(ventilation_mode) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if self._device.ventilation_mode == VENTILATION_MODE_STOP: + return HVACMode.OFF + + return HVACMode.FAN_ONLY + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + try: + if hvac_mode == HVACMode.OFF: + await self._device.set_ventilation_mode(VENTILATION_MODE_STOP) + else: + await self._device.set_ventilation_mode(VENTILATION_MODE_HOME) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc diff --git a/homeassistant/components/flexit_bacnet/config_flow.py b/homeassistant/components/flexit_bacnet/config_flow.py new file mode 100644 index 00000000000..2c87dfc5b97 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions +import logging +from typing import Any + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICE_ID = 2 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_DEVICE_ID, default=DEFAULT_DEVICE_ID): int, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Flexit Nordic (BACnet).""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + device = FlexitBACnet( + user_input[CONF_IP_ADDRESS], user_input[CONF_DEVICE_ID] + ) + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=device.device_name, data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/flexit_bacnet/const.py b/homeassistant/components/flexit_bacnet/const.py new file mode 100644 index 00000000000..269a88c4cec --- /dev/null +++ b/homeassistant/components/flexit_bacnet/const.py @@ -0,0 +1,30 @@ +"""Constants for the Flexit Nordic (BACnet) integration.""" +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HIGH, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, +) + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + PRESET_NONE, +) + +DOMAIN = "flexit_bacnet" + +VENTILATION_TO_PRESET_MODE_MAP = { + VENTILATION_MODE_STOP: PRESET_NONE, + VENTILATION_MODE_AWAY: PRESET_AWAY, + VENTILATION_MODE_HOME: PRESET_HOME, + VENTILATION_MODE_HIGH: PRESET_BOOST, +} + +PRESET_TO_VENTILATION_MODE_MAP = { + PRESET_NONE: VENTILATION_MODE_STOP, + PRESET_AWAY: VENTILATION_MODE_AWAY, + PRESET_HOME: VENTILATION_MODE_HOME, + PRESET_BOOST: VENTILATION_MODE_HIGH, +} diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json new file mode 100644 index 00000000000..d230e4ebb7a --- /dev/null +++ b/homeassistant/components/flexit_bacnet/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "flexit_bacnet", + "name": "Flexit Nordic (BACnet)", + "codeowners": ["@lellky", "@piotrbulinski"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/flexit_bacnet", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["flexit_bacnet==2.1.0"] +} diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json new file mode 100644 index 00000000000..fd2725c6403 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "device_id": "[%key:common::config_flow::data::device%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 57503f0ef32..30c884249a9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -149,6 +149,7 @@ FLOWS = { "fitbit", "fivem", "fjaraskupan", + "flexit_bacnet", "flick_electric", "flipr", "flo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f0af72624f6..394b40ac630 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1766,9 +1766,20 @@ }, "flexit": { "name": "Flexit", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" + "integrations": { + "flexit": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_polling", + "name": "Flexit" + }, + "flexit_bacnet": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "Flexit Nordic (BACnet)" + } + } }, "flexom": { "name": "Bouygues Flexom", diff --git a/mypy.ini b/mypy.ini index a27282fc667..05525d03300 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1041,6 +1041,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.flexit_bacnet.*] +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.flux_led.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index de84d8fb420..7f1b3d338f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -822,6 +822,9 @@ fixerio==1.0.0a0 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2241b1c5d4..5f379b9a355 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -651,6 +651,9 @@ fivem-api==0.1.2 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 diff --git a/tests/components/flexit_bacnet/__init__.py b/tests/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000..4cae6e4f4bf --- /dev/null +++ b/tests/components/flexit_bacnet/__init__.py @@ -0,0 +1 @@ +"""Tests for the Flexit Nordic (BACnet) integration.""" diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py new file mode 100644 index 00000000000..b136b134e01 --- /dev/null +++ b/tests/components/flexit_bacnet/conftest.py @@ -0,0 +1,44 @@ +"""Configuration for Flexit Nordic (BACnet) tests.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +async def flow_id(hass: HomeAssistant) -> str: + """Return initial ID for user-initiated configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + return result["flow_id"] + + +@pytest.fixture(autouse=True) +def mock_serial_number_and_device_name(): + """Mock serial number of the device.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.serial_number", + "0000-0001", + ), patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.device_name", + "Device Name", + ): + yield + + +@pytest.fixture +def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.flexit_bacnet.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock diff --git a/tests/components/flexit_bacnet/test_config_flow.py b/tests/components/flexit_bacnet/test_config_flow.py new file mode 100644 index 00000000000..ed513587af6 --- /dev/null +++ b/tests/components/flexit_bacnet/test_config_flow.py @@ -0,0 +1,120 @@ +"""Test the Flexit Nordic (BACnet) config flow.""" +import asyncio.exceptions +from unittest.mock import patch + +from flexit_bacnet import DecodingError +import pytest + +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, flow_id: str, mock_setup_entry) -> None: + """Test we get the form and the happy path works.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Device Name" + assert result["context"]["unique_id"] == "0000-0001" + assert result["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + asyncio.exceptions.TimeoutError, + "cannot_connect", + ), + (ConnectionError, "cannot_connect"), + (DecodingError, "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_fails( + hass: HomeAssistant, flow_id: str, error: Exception, message: str, mock_setup_entry +) -> None: + """Test that we return 'cannot_connect' error when attempting to connect to an incorrect IP address. + + The flexit_bacnet library raises asyncio.exceptions.TimeoutError in that scenario. + """ + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update", + side_effect=error, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": message} + assert len(mock_setup_entry.mock_calls) == 0 + + # ensure that user can recover from this error + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Device Name" + assert result2["context"]["unique_id"] == "0000-0001" + assert result2["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_device_already_exist(hass: HomeAssistant, flow_id: str) -> None: + """Test that we cannot add already added device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + unique_id="0000-0001", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" From 3bbed8965ab6cb70d1ceebda107df573d4ddf1c6 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:55:53 +0100 Subject: [PATCH 290/643] Frontier to Glances: add host field description (#104787) Co-authored-by: Franck Nijhof --- homeassistant/components/frontier_silicon/strings.json | 5 ++++- homeassistant/components/fully_kiosk/strings.json | 3 +++ homeassistant/components/glances/strings.json | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index a10c3f535a1..03d9f28c016 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -5,10 +5,13 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Frontier Silicon device." } }, "device_config": { - "title": "Device Configuration", + "title": "Device configuration", "description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'", "data": { "pin": "[%key:common::config_flow::data::pin%]" diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index bf46feeec3f..c1a1ef1fcf0 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -13,6 +13,9 @@ "password": "[%key:common::config_flow::data::password%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the device running your Fully Kiosk Browser application." } } }, diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index fdd0c44b31b..1bab098d65f 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -10,6 +10,9 @@ "version": "Glances API Version (2 or 3)", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system running your Glances system monitor." } }, "reauth_confirm": { From cc2c7c7be1dfa794cf1eebaa9872501790f9a317 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:57:44 +0100 Subject: [PATCH 291/643] KMtronic to LG Soundbar: add host field description (#104792) --- homeassistant/components/kmtronic/strings.json | 3 +++ homeassistant/components/kodi/strings.json | 3 +++ homeassistant/components/lg_soundbar/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json index 2a3a3a40687..6cecea12f22 100644 --- a/homeassistant/components/kmtronic/strings.json +++ b/homeassistant/components/kmtronic/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your KMtronic device." } } }, diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 51431b317d6..7c7d53b33ac 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system hosting your Kodi server." } }, "discovery_confirm": { diff --git a/homeassistant/components/lg_soundbar/strings.json b/homeassistant/components/lg_soundbar/strings.json index 8c6a9909ff5..ee16a39350c 100644 --- a/homeassistant/components/lg_soundbar/strings.json +++ b/homeassistant/components/lg_soundbar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LG Soundbar." } } }, From 52450291cb512f0db138316d0a1704bda2b1c91c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 16:59:26 +0100 Subject: [PATCH 292/643] Fix device sync to Google Assistant if Matter integration is active (#104796) * Only get Matter device info if device is an actual Matter device * Return None if matter device does not exist * lint * fix test * adjust google assistant test --- homeassistant/components/google_assistant/helpers.py | 8 ++++++-- homeassistant/components/matter/helpers.py | 2 +- tests/components/google_assistant/test_helpers.py | 1 + tests/components/matter/test_helpers.py | 11 ++++------- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index af892f15af4..c89925664e0 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -686,8 +686,12 @@ class GoogleEntity: return device # Add Matter info - if "matter" in self.hass.config.components and ( - matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + if ( + "matter" in self.hass.config.components + and any(x for x in device_entry.identifiers if x[0] == "matter") + and ( + matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + ) ): device["matterUniqueId"] = matter_info["unique_id"] device["matterOriginalVendorId"] = matter_info["vendor_id"] diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index dcd6a30ee1f..446d5dc3591 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -94,7 +94,7 @@ def get_node_from_device_entry( ) if device_id_full is None: - raise ValueError(f"Device {device.id} is not a Matter device") + return None device_id = device_id_full.lstrip(device_id_type_prefix) matter_client = matter.matter_client diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 771df137278..aaa3949caaf 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -89,6 +89,7 @@ async def test_google_entity_sync_serialize_with_matter( manufacturer="Someone", model="Some model", sw_version="Some Version", + identifiers={("matter", "12345678")}, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity = entity_registry.async_get_or_create( diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index c7a0ed0d8a3..61988a37122 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -60,16 +60,13 @@ async def test_get_node_from_device_entry( assert node_from_device_entry is node - with pytest.raises(ValueError) as value_error: - await get_node_from_device_entry(hass, other_device_entry) - - assert f"Device {other_device_entry.id} is not a Matter device" in str( - value_error.value - ) + # test non-Matter device returns None + assert get_node_from_device_entry(hass, other_device_entry) is None matter_client.server_info = None + # test non-initialized server raises RuntimeError with pytest.raises(RuntimeError) as runtime_error: - node_from_device_entry = await get_node_from_device_entry(hass, device_entry) + node_from_device_entry = get_node_from_device_entry(hass, device_entry) assert "Matter server information is not available" in str(runtime_error.value) From b9ab28150ea1722a0baa4a12c0f89ad85baec1a2 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:03:27 +0100 Subject: [PATCH 293/643] iAlarm to Keenetic: add host field description (#104791) Co-authored-by: Andrey Kupreychik --- homeassistant/components/ialarm/strings.json | 3 +++ homeassistant/components/iotawatt/strings.json | 3 +++ homeassistant/components/keenetic_ndms2/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ialarm/strings.json b/homeassistant/components/ialarm/strings.json index 1ac7a25e6f8..cb2c75d74a9 100644 --- a/homeassistant/components/ialarm/strings.json +++ b/homeassistant/components/ialarm/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of Antifurto365 iAlarm system." } } }, diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json index f21dfe0cd09..266b32c5c31 100644 --- a/homeassistant/components/iotawatt/strings.json +++ b/homeassistant/components/iotawatt/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your IoTaWatt device." } }, "auth": { diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 13e3fabfbff..765a3fc4d47 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -9,6 +9,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Keenetic router." } } }, From 41fb8d50ccb81cc3de21f4467a799b9a4d3c7dc9 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:04:15 +0100 Subject: [PATCH 294/643] Goalzero to HEOS: add host field description (#104786) --- homeassistant/components/goalzero/strings.json | 3 +++ homeassistant/components/harmony/strings.json | 3 +++ homeassistant/components/heos/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index d94f5219607..c6d85bd4c10 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Goal Zero Yeti." } }, "confirm_discovery": { diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index c9c7a559758..f6862ca3c83 100644 --- a/homeassistant/components/harmony/strings.json +++ b/homeassistant/components/harmony/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "Hub Name" + }, + "data_description": { + "host": "The hostname or IP address of your Logitech Harmony Hub." } }, "link": { diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 7bd362cf3d7..df18fc7834a 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -6,6 +6,9 @@ "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your HEOS device." } } }, From 419dc8adb1026c57a3fe5f004bcd2c6d76710f9f Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:05:32 +0100 Subject: [PATCH 295/643] Freebox to FRITZ!Box add host field description (#104784) Co-authored-by: Simone Chemelli --- homeassistant/components/freebox/strings.json | 3 +++ homeassistant/components/fritz/strings.json | 3 +++ homeassistant/components/fritzbox/strings.json | 3 +++ homeassistant/components/fritzbox_callmonitor/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json index 5c4143b4562..eaa56a38da1 100644 --- a/homeassistant/components/freebox/strings.json +++ b/homeassistant/components/freebox/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Freebox router." } }, "link": { diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 7cbb10a236b..5eed2f59fc4 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -26,6 +26,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } } }, diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index d5607aa3090..f4d2fe3670e 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "confirm": { diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 89f049bfbe9..ac36942eec2 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -8,6 +8,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "phonebook": { From 7ec651022105c8d94c1f811b778f69b246299d60 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:09:06 +0100 Subject: [PATCH 296/643] Add significant Change support for remote (#104627) --- .../components/remote/significant_change.py | 27 ++++++++ .../remote/test_significant_change.py | 62 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 homeassistant/components/remote/significant_change.py create mode 100644 tests/components/remote/test_significant_change.py diff --git a/homeassistant/components/remote/significant_change.py b/homeassistant/components/remote/significant_change.py new file mode 100644 index 00000000000..8e5a3669041 --- /dev/null +++ b/homeassistant/components/remote/significant_change.py @@ -0,0 +1,27 @@ +"""Helper to test significant Remote state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_CURRENT_ACTIVITY + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + if old_attrs.get(ATTR_CURRENT_ACTIVITY) != new_attrs.get(ATTR_CURRENT_ACTIVITY): + return True + + return False diff --git a/tests/components/remote/test_significant_change.py b/tests/components/remote/test_significant_change.py new file mode 100644 index 00000000000..dcbfce213d6 --- /dev/null +++ b/tests/components/remote/test_significant_change.py @@ -0,0 +1,62 @@ +"""Test the Remote significant change platform.""" +from homeassistant.components.remote import ATTR_ACTIVITY_LIST, ATTR_CURRENT_ACTIVITY +from homeassistant.components.remote.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_change() -> None: + """Detect Remote significant changes.""" + # no change at all + attrs = { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + } + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + + # change of state is significant + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + # change of current activity is significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "paused", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + } + assert async_check_significant_change(None, "on", attrs["old"], "on", attrs["new"]) + + # change of list of possible activities is not significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing"], + }, + } + assert not async_check_significant_change( + None, "on", attrs["old"], "on", attrs["new"] + ) + + # change of any not official attribute is not significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + "not_official": "changed", + }, + } + assert not async_check_significant_change( + None, "on", attrs["old"], "on", attrs["new"] + ) From b31d805de1c1f0afa1233eba0467079693f6d1a9 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 17:09:53 +0100 Subject: [PATCH 297/643] Bump python-matter-server to version 5.0.0 (#104805) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 174ebb1cab9..f350cda9227 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==4.0.2"] + "requirements": ["python-matter-server==5.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7f1b3d338f4..33b4778951d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2172,7 +2172,7 @@ python-kasa[speedups]==0.5.4 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f379b9a355..3c774e2e538 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1626,7 +1626,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From b3d64c39499383dd98e67c1232072407269c5e44 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 30 Nov 2023 17:11:28 +0100 Subject: [PATCH 298/643] Fix Fastdotcom no entity (#104785) Co-authored-by: G Johansson --- homeassistant/components/fastdotcom/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 33ad4853404..939ab4a40e5 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -24,7 +24,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fast.com sensor.""" - async_add_entities([SpeedtestSensor(hass.data[DOMAIN])]) + async_add_entities([SpeedtestSensor(entry.entry_id, hass.data[DOMAIN])]) # pylint: disable-next=hass-invalid-inheritance # needs fixing @@ -38,9 +38,10 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): _attr_icon = "mdi:speedometer" _attr_should_poll = False - def __init__(self, speedtest_data: dict[str, Any]) -> None: + def __init__(self, entry_id: str, speedtest_data: dict[str, Any]) -> None: """Initialize the sensor.""" self._speedtest_data = speedtest_data + self._attr_unique_id = entry_id async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" From 0c08081386e7fa8e5f8769d4d3c8de368055e4ee Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:24:21 +0100 Subject: [PATCH 299/643] Deconz to DoorBird: add host field description (#104772) * Deconz to DoorBird: add host field description * Update homeassistant/components/deconz/strings.json Co-authored-by: Robert Svensson --------- Co-authored-by: Robert Svensson --- homeassistant/components/deconz/strings.json | 5 ++++- homeassistant/components/deluge/strings.json | 3 +++ homeassistant/components/directv/strings.json | 3 +++ homeassistant/components/dlink/strings.json | 1 + homeassistant/components/doorbird/strings.json | 5 ++++- 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index e32ab875c28..c06a07e6ce5 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -11,11 +11,14 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your deCONZ host." } }, "link": { "title": "Link with deCONZ", - "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button" + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Press \"Authenticate app\" button" }, "hassio_confirm": { "title": "deCONZ Zigbee gateway via Home Assistant add-on", diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index e0266d004e2..52706f39894 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", "web_port": "Web port (for visiting service)" + }, + "data_description": { + "host": "The hostname or IP address of your Deluge device." } } }, diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json index 8ed52cd3632..2c30e3db85c 100644 --- a/homeassistant/components/directv/strings.json +++ b/homeassistant/components/directv/strings.json @@ -8,6 +8,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your DirectTV device." } } }, diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index 8c60d59fa6b..9f21a9571e9 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -9,6 +9,7 @@ "use_legacy_protocol": "Use legacy protocol" }, "data_description": { + "host": "The hostname or IP address of your D-Link device", "password": "Default: PIN code on the back." } }, diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index ceaf1a891ee..c851de379d4 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -17,8 +17,11 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "host": "[%key:common::config_flow::data::host%]", - "name": "Device Name", + "name": "Device name", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your DoorBird device." } } }, From 3133585d5209b7077efa8e25b48c4b4e0271acb6 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:25:04 +0100 Subject: [PATCH 300/643] Enphase to Evil: add host field description (#104779) Co-authored-by: Franck Nijhof --- homeassistant/components/enphase_envoy/strings.json | 3 +++ homeassistant/components/epson/strings.json | 3 +++ homeassistant/components/evil_genius_labs/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 94cf9233745..fe32002e6b2 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Enphase Envoy gateway." } } }, diff --git a/homeassistant/components/epson/strings.json b/homeassistant/components/epson/strings.json index 4e3780322e9..94544c32d1d 100644 --- a/homeassistant/components/epson/strings.json +++ b/homeassistant/components/epson/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Epson projector." } } }, diff --git a/homeassistant/components/evil_genius_labs/strings.json b/homeassistant/components/evil_genius_labs/strings.json index 790e9a69c7f..123d164444d 100644 --- a/homeassistant/components/evil_genius_labs/strings.json +++ b/homeassistant/components/evil_genius_labs/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Evil Genius Labs device." } } }, From b7bf1e9f3f5e04c9911951d19919f66b50061542 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Nov 2023 17:26:07 +0100 Subject: [PATCH 301/643] Make Shelly Wall Display thermostat implementation compatible with firmware 1.2.5 (#104812) --- homeassistant/components/shelly/climate.py | 11 ++++------- homeassistant/components/shelly/switch.py | 5 +++-- homeassistant/components/shelly/utils.py | 7 ------- tests/components/shelly/conftest.py | 3 +-- tests/components/shelly/test_switch.py | 11 +++++++---- 5 files changed, 15 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index d855e8b238b..6a592c904f6 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -42,12 +42,7 @@ from .const import ( ) from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyRpcEntity -from .utils import ( - async_remove_shelly_entity, - get_device_entry_gen, - get_rpc_key_ids, - is_relay_used_as_actuator, -) +from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( @@ -131,7 +126,9 @@ def async_setup_rpc_entry( for id_ in climate_key_ids: climate_ids.append(id_) - if is_relay_used_as_actuator(id_, coordinator.mac, coordinator.device.config): + if coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is used as the thermostat actuator, + # we need to remove a switch entity unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "switch", unique_id) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 35429c858f5..5a398182e4d 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -118,8 +118,9 @@ def async_setup_rpc_entry( continue if coordinator.model == MODEL_WALL_DISPLAY: - if coordinator.device.shelly["relay_operational"]: - # Wall Display in relay mode, we need to remove a climate entity + if not coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is not used as the thermostat actuator, + # we need to remove a climate entity unique_id = f"{coordinator.mac}-thermostat:{id_}" async_remove_shelly_entity(hass, "climate", unique_id) else: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 0209dc63aa8..6b5c59f28db 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -430,10 +430,3 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: return None return GEN1_RELEASE_URL if gen == 1 else GEN2_RELEASE_URL - - -def is_relay_used_as_actuator(relay_id: int, mac: str, config: dict[str, Any]) -> bool: - """Return True if an internal relay is used as the thermostat actuator.""" - return f"{mac}/c/switch:{relay_id}".lower() in config[f"thermostat:{relay_id}"].get( - "actuator", "" - ) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 1662405dc80..6eb74e26dcb 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -153,7 +153,6 @@ MOCK_CONFIG = { "id": 0, "enable": True, "type": "heating", - "actuator": f"shelly://shellywalldisplay-{MOCK_MAC.lower()}/c/switch:0", }, "sys": { "ui_data": {}, @@ -181,7 +180,7 @@ MOCK_SHELLY_RPC = { "auth_en": False, "auth_domain": None, "profile": "cover", - "relay_operational": False, + "relay_in_thermostat": True, } MOCK_STATUS_COAP = { diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 69e1423f75a..e19416706e1 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -283,7 +283,8 @@ async def test_block_device_gas_valve( async def test_wall_display_thermostat_mode( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, + mock_rpc_device, ) -> None: """Test Wall Display in thermostat mode.""" await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) @@ -294,7 +295,10 @@ async def test_wall_display_thermostat_mode( async def test_wall_display_relay_mode( - hass: HomeAssistant, entity_registry, mock_rpc_device, monkeypatch + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_rpc_device, + monkeypatch, ) -> None: """Test Wall Display in thermostat mode.""" entity_id = register_entity( @@ -305,8 +309,7 @@ async def test_wall_display_relay_mode( ) new_shelly = deepcopy(mock_rpc_device.shelly) - new_shelly["relay_operational"] = True - + new_shelly["relay_in_thermostat"] = False monkeypatch.setattr(mock_rpc_device, "shelly", new_shelly) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) From 12902b8a6828134308b60cacca63bf4e695f7f47 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 17:45:27 +0100 Subject: [PATCH 302/643] Add NodeStrClass.__voluptuous_compile__ (#104808) --- homeassistant/util/yaml/objects.py | 7 +++++++ tests/util/yaml/test_init.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index b2320a74d2c..6aedc85cf60 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -2,7 +2,10 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any +import voluptuous as vol +from voluptuous.schema_builder import _compile_scalar import yaml @@ -13,6 +16,10 @@ class NodeListClass(list): class NodeStrClass(str): """Wrapper class to be able to add attributes on a string.""" + def __voluptuous_compile__(self, schema: vol.Schema) -> Any: + """Needed because vol.Schema.compile does not handle str subclasses.""" + return _compile_scalar(self) + class NodeDictClass(dict): """Wrapper class to be able to add attributes on a dict.""" diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 990956ec908..a4b243a4b4a 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -8,6 +8,7 @@ import unittest from unittest.mock import patch import pytest +import voluptuous as vol import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file @@ -615,3 +616,20 @@ def test_string_annotated(try_both_loaders) -> None: getattr(value, "__config_file__", None) == expected_annotations[key][1][0] ) assert getattr(value, "__line__", None) == expected_annotations[key][1][1] + + +def test_string_used_as_vol_schema(try_both_loaders) -> None: + """Test the subclassed strings can be used in voluptuous schemas.""" + conf = "wanted_data:\n key_1: value_1\n key_2: value_2\n" + with io.StringIO(conf) as file: + doc = yaml_loader.parse_yaml(file) + + # Test using the subclassed strings in a schema + schema = vol.Schema( + {vol.Required(key): value for key, value in doc["wanted_data"].items()}, + ) + # Test using the subclassed strings when validating a schema + schema(doc["wanted_data"]) + schema({"key_1": "value_1", "key_2": "value_2"}) + with pytest.raises(vol.Invalid): + schema({"key_1": "value_2", "key_2": "value_1"}) From 99523c96a22dd00883b525634a63c6bac5d3ba02 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Nov 2023 17:49:31 +0100 Subject: [PATCH 303/643] Update frontend to 20231130.0 (#104816) --- 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 7a587d56d74..b6668383b54 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==20231129.1"] + "requirements": ["home-assistant-frontend==20231130.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 75209de5996..7dad258068d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 33b4778951d..9566156f7ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c774e2e538..917da82ee30 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -804,7 +804,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From 9447d954d6ab66e1063ef7b91a837bbf47444a61 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Thu, 30 Nov 2023 10:14:22 -0700 Subject: [PATCH 304/643] Add codeowner to vesync (#104809) --- CODEOWNERS | 4 ++-- homeassistant/components/vesync/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index d41975259b5..6645a6f06a3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1400,8 +1400,8 @@ build.json @home-assistant/supervisor /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus -/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey -/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey +/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja +/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja /homeassistant/components/vicare/ @CFenner /tests/components/vicare/ @CFenner /homeassistant/components/vilfo/ @ManneW diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index fb892acfd4f..ff3f56dd184 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -1,7 +1,7 @@ { "domain": "vesync", "name": "VeSync", - "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey"], + "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey", "@cdnninja"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", From abc05451a280ba675e654e0cb8d8c6c925dd2a87 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 18:14:48 +0100 Subject: [PATCH 305/643] Restore renamed yaml loader classes and warn when used (#104818) --- homeassistant/util/yaml/loader.py | 25 ++++++++++++++ tests/util/yaml/test_init.py | 55 ++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index fbffae448b2..e8f4a734bdb 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -23,6 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -136,6 +137,18 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets +class SafeLoader(FastSafeLoader): + """Provided for backwards compatibility. Logs when instantiated.""" + + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + report( + "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + FastSafeLoader.__init__(*args, **kwargs) + + class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -145,6 +158,18 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets +class SafeLineLoader(PythonSafeLoader): + """Provided for backwards compatibility. Logs when instantiated.""" + + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + report( + "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + PythonSafeLoader.__init__(*args, **kwargs) + + LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index a4b243a4b4a..c4e5c58e235 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -1,11 +1,12 @@ """Test Home Assistant yaml loader.""" +from collections.abc import Generator import importlib import io import os import pathlib from typing import Any import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest import voluptuous as vol @@ -585,6 +586,58 @@ async def test_loading_actual_file_with_syntax_error( await hass.async_add_executor_job(load_yaml_config_file, fixture_path) +@pytest.fixture +def mock_integration_frame() -> Generator[Mock, None, None]: + """Mock as if we're calling code from inside an integration.""" + correct_frame = Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ) + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + yield correct_frame + + +@pytest.mark.parametrize( + ("loader_class", "message"), + [ + (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), + ( + yaml.loader.SafeLineLoader, + "'SafeLineLoader' instead of 'PythonSafeLoader'", + ), + ], +) +async def test_deprecated_loaders( + hass: HomeAssistant, + mock_integration_frame: Mock, + caplog: pytest.LogCaptureFixture, + loader_class, + message: str, +) -> None: + """Test instantiating the deprecated yaml loaders logs a warning.""" + with pytest.raises(TypeError), patch( + "homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set() + ): + loader_class() + assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text + + def test_string_annotated(try_both_loaders) -> None: """Test strings are annotated with file + line.""" conf = ( From f50cd5ab5e46319f2cd495a7c9de9effc8c37073 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 30 Nov 2023 17:17:34 +0000 Subject: [PATCH 306/643] Bump evohome-async to 0.4.9 (#103660) --- homeassistant/components/evohome/__init__.py | 67 ++++++++----------- homeassistant/components/evohome/climate.py | 43 +++++++----- .../components/evohome/manifest.json | 2 +- .../components/evohome/water_heater.py | 9 +-- requirements_all.txt | 2 +- 5 files changed, 60 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index f4ceaf2c48c..9c33b0fbf31 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -190,14 +190,14 @@ def _handle_exception(err) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Create a (EMEA/EU-based) Honeywell TCC system.""" - async def load_auth_tokens(store) -> tuple[dict, dict | None]: + async def load_auth_tokens(store) -> tuple[dict[str, str | dt], dict[str, str]]: app_storage = await store.async_load() tokens = dict(app_storage or {}) if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]: # any tokens won't be valid, and store might be corrupt await store.async_save({}) - return ({}, None) + return ({}, {}) # evohomeasync2 requires naive/local datetimes as strings if tokens.get(ACCESS_TOKEN_EXPIRES) is not None and ( @@ -205,7 +205,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ): tokens[ACCESS_TOKEN_EXPIRES] = _dt_aware_to_naive(expires) - user_data = tokens.pop(USER_DATA, None) + user_data = tokens.pop(USER_DATA, {}) return (tokens, user_data) store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY) @@ -214,7 +214,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client_v2 = evohomeasync2.EvohomeClient( config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD], - **tokens, + **tokens, # type: ignore[arg-type] session=async_get_clientsession(hass), ) @@ -253,7 +253,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client_v1 = evohomeasync.EvohomeClient( client_v2.username, client_v2.password, - user_data=user_data, + session_id=user_data.get("sessionId") if user_data else None, # STORAGE_VER 1 session=async_get_clientsession(hass), ) @@ -425,7 +425,7 @@ class EvoBroker: self.tcs_utc_offset = timedelta( minutes=client.locations[loc_idx].timeZone[UTC_OFFSET] ) - self.temps: dict[str, Any] | None = {} + self.temps: dict[str, float | None] = {} async def save_auth_tokens(self) -> None: """Save access tokens and session IDs to the store for later use.""" @@ -441,14 +441,12 @@ class EvoBroker: ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), } - if self.client_v1 and self.client_v1.user_data: - user_id = self.client_v1.user_data["userInfo"]["userID"] # type: ignore[index] + if self.client_v1: app_storage[USER_DATA] = { # type: ignore[assignment] - "userInfo": {"userID": user_id}, - "sessionId": self.client_v1.user_data["sessionId"], - } + "sessionId": self.client_v1.broker.session_id, + } # this is the schema for STORAGE_VER == 1 else: - app_storage[USER_DATA] = None + app_storage[USER_DATA] = {} # type: ignore[assignment] await self._store.async_save(app_storage) @@ -468,16 +466,13 @@ class EvoBroker: async def _update_v1_api_temps(self, *args, **kwargs) -> None: """Get the latest high-precision temperatures of the default Location.""" - assert self.client_v1 + assert self.client_v1 # mypy check - def get_session_id(client_v1) -> str | None: - user_data = client_v1.user_data if client_v1 else None - return user_data.get("sessionId") if user_data else None - - session_id = get_session_id(self.client_v1) + session_id = self.client_v1.broker.session_id # maybe receive a new session_id? + self.temps = {} # these are now stale, will fall back to v2 temps try: - temps = list(await self.client_v1.temperatures(force_refresh=True)) + temps = await self.client_v1.get_temperatures() except evohomeasync.InvalidSchema as exc: _LOGGER.warning( @@ -489,7 +484,7 @@ class EvoBroker: ), exc, ) - self.temps = self.client_v1 = None + self.client_v1 = None except evohomeasync.EvohomeError as exc: _LOGGER.warning( @@ -501,7 +496,6 @@ class EvoBroker: ), exc, ) - self.temps = None # these are now stale, will fall back to v2 temps else: if ( @@ -513,19 +507,20 @@ class EvoBroker: "the v1 API's default location (there is more than one location), " "so the high-precision feature will be disabled until next restart" ) - self.temps = self.client_v1 = None + self.client_v1 = None else: self.temps = {str(i["id"]): i["temp"] for i in temps} finally: - if session_id != get_session_id(self.client_v1): + if self.client_v1 and session_id != self.client_v1.broker.session_id: await self.save_auth_tokens() _LOGGER.debug("Temperatures = %s", self.temps) async def _update_v2_api_state(self, *args, **kwargs) -> None: """Get the latest modes, temperatures, setpoints of a Location.""" - access_token = self.client.access_token + + access_token = self.client.access_token # maybe receive a new token? loc_idx = self.params[CONF_LOCATION_IDX] try: @@ -536,9 +531,9 @@ class EvoBroker: async_dispatcher_send(self.hass, DOMAIN) _LOGGER.debug("Status = %s", status) - - if access_token != self.client.access_token: - await self.save_auth_tokens() + finally: + if access_token != self.client.access_token: + await self.save_auth_tokens() async def async_update(self, *args, **kwargs) -> None: """Get the latest state data of an entire Honeywell TCC Location. @@ -562,6 +557,8 @@ class EvoDevice(Entity): _attr_should_poll = False + _evo_id: str + def __init__(self, evo_broker, evo_device) -> None: """Initialize the evohome entity.""" self._evo_device = evo_device @@ -623,18 +620,10 @@ class EvoChild(EvoDevice): @property def current_temperature(self) -> float | None: """Return the current temperature of a Zone.""" - if self._evo_device.TYPE == "domesticHotWater": - dev_id = self._evo_device.dhwId - else: - dev_id = self._evo_device.zoneId - if self._evo_broker.temps and self._evo_broker.temps[dev_id] is not None: - return self._evo_broker.temps[dev_id] - - if self._evo_device.temperatureStatus["isAvailable"]: - return self._evo_device.temperatureStatus["temperature"] - - return None + if self._evo_broker.temps.get(self._evo_id) is not None: + return self._evo_broker.temps[self._evo_id] + return self._evo_device.temperature @property def setpoints(self) -> dict[str, Any]: @@ -679,7 +668,7 @@ class EvoChild(EvoDevice): switchpoint_time_of_day = dt_util.parse_datetime( f"{sp_date}T{switchpoint['TimeOfDay']}" ) - assert switchpoint_time_of_day + assert switchpoint_time_of_day # mypy check dt_aware = _dt_evo_to_aware( switchpoint_time_of_day, self._evo_broker.tcs_utc_offset ) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index fb608262a7d..dea5676d332 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -150,6 +150,7 @@ class EvoZone(EvoChild, EvoClimateEntity): self._attr_unique_id = f"{evo_device.zoneId}z" else: self._attr_unique_id = evo_device.zoneId + self._evo_id = evo_device.zoneId self._attr_name = evo_device.name @@ -189,24 +190,27 @@ class EvoZone(EvoChild, EvoClimateEntity): ) @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return the current operating mode of a Zone.""" - if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): + if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): return HVACMode.AUTO - is_off = self.target_temperature <= self.min_temp - return HVACMode.OFF if is_off else HVACMode.HEAT + if self.target_temperature is None: + return None + if self.target_temperature <= self.min_temp: + return HVACMode.OFF + return HVACMode.HEAT @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the target temperature of a Zone.""" - return self._evo_device.setpointStatus["targetHeatTemperature"] + return self._evo_device.target_heat_temperature @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): - return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) - return EVO_PRESET_TO_HA.get(self._evo_device.setpointStatus["setpointMode"]) + if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): + return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) + return EVO_PRESET_TO_HA.get(self._evo_device.mode) @property def min_temp(self) -> float: @@ -214,7 +218,7 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 5, but is user-configurable within 5-35 (in Celsius). """ - return self._evo_device.setpointCapabilities["minHeatSetpoint"] + return self._evo_device.min_heat_setpoint @property def max_temp(self) -> float: @@ -222,17 +226,17 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 35, but is user-configurable within 5-35 (in Celsius). """ - return self._evo_device.setpointCapabilities["maxHeatSetpoint"] + return self._evo_device.max_heat_setpoint async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature.""" temperature = kwargs["temperature"] if (until := kwargs.get("until")) is None: - if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: + if self._evo_device.mode == EVO_FOLLOW: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) - elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER: + elif self._evo_device.mode == EVO_TEMPOVER: until = dt_util.parse_datetime(self._evo_device.setpointStatus["until"]) until = dt_util.as_utc(until) if until else None @@ -272,7 +276,7 @@ class EvoZone(EvoChild, EvoClimateEntity): await self._evo_broker.call_client_api(self._evo_device.reset_mode()) return - temperature = self._evo_device.setpointStatus["targetHeatTemperature"] + temperature = self._evo_device.target_heat_temperature if evo_preset_mode == EVO_TEMPOVER: await self._update_schedule() @@ -311,6 +315,7 @@ class EvoController(EvoClimateEntity): super().__init__(evo_broker, evo_device) self._attr_unique_id = evo_device.systemId + self._evo_id = evo_device.systemId self._attr_name = evo_device.location.name modes = [m["systemMode"] for m in evo_broker.config["allowedSystemModes"]] @@ -352,7 +357,7 @@ class EvoController(EvoClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Controller.""" - tcs_mode = self._evo_tcs.systemModeStatus["mode"] + tcs_mode = self._evo_tcs.system_mode return HVACMode.OFF if tcs_mode == EVO_HEATOFF else HVACMode.HEAT @property @@ -362,16 +367,18 @@ class EvoController(EvoClimateEntity): Controllers do not have a current temp, but one is expected by HA. """ temps = [ - z.temperatureStatus["temperature"] + z.temperature for z in self._evo_tcs.zones.values() - if z.temperatureStatus["isAvailable"] + if z.temperature is not None ] return round(sum(temps) / len(temps), 1) if temps else None @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) + if not self._evo_tcs.system_mode: + return None + return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Raise exception as Controllers don't have a target temperature.""" diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 58efb2c25b2..769c8e597cd 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.6"] + "requirements": ["evohome-async==0.4.9"] } diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 5d49e9b46ec..51617bdf1cf 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -68,6 +68,7 @@ class EvoDHW(EvoChild, WaterHeaterEntity): super().__init__(evo_broker, evo_device) self._attr_unique_id = evo_device.dhwId + self._evo_id = evo_device.dhwId self._attr_precision = ( PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE @@ -79,15 +80,15 @@ class EvoDHW(EvoChild, WaterHeaterEntity): @property def current_operation(self) -> str: """Return the current operating mode (Auto, On, or Off).""" - if self._evo_device.stateStatus["mode"] == EVO_FOLLOW: + if self._evo_device.mode == EVO_FOLLOW: return STATE_AUTO - return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] + return EVO_STATE_TO_HA[self._evo_device.state] @property def is_away_mode_on(self): """Return True if away mode is on.""" - is_off = EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] == STATE_OFF - is_permanent = self._evo_device.stateStatus["mode"] == EVO_PERMOVER + is_off = EVO_STATE_TO_HA[self._evo_device.state] == STATE_OFF + is_permanent = self._evo_device.mode == EVO_PERMOVER return is_off and is_permanent async def async_set_operation_mode(self, operation_mode: str) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 9566156f7ea..8ed260e654f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -789,7 +789,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.6 +evohome-async==0.4.9 # homeassistant.components.faa_delays faadelays==2023.9.1 From 689c0d14ec0546509de96e0650398da982bf080e Mon Sep 17 00:00:00 2001 From: Sergiy Maysak Date: Thu, 30 Nov 2023 19:19:10 +0200 Subject: [PATCH 307/643] Added typing for return value for async_migrate func. (#104828) --- homeassistant/components/wirelesstag/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index f95337dbaf4..cfbdb6bdc92 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -128,7 +128,9 @@ class WirelessTagPlatform: self.api.start_monitoring(push_callback) -def async_migrate_unique_id(hass: HomeAssistant, tag: SensorTag, domain: str, key: str): +def async_migrate_unique_id( + hass: HomeAssistant, tag: SensorTag, domain: str, key: str +) -> None: """Migrate old unique id to new one with use of tag's uuid.""" registry = er.async_get(hass) new_unique_id = f"{tag.uuid}_{key}" From 4bc1e5075a9f5a00d5d1723d69e3d0b623b239a8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Nov 2023 18:45:04 +0100 Subject: [PATCH 308/643] Revert "Add Komfovent (#95722)" (#104819) --- .coveragerc | 2 - CODEOWNERS | 2 - .../components/komfovent/__init__.py | 34 ---- homeassistant/components/komfovent/climate.py | 91 --------- .../components/komfovent/config_flow.py | 74 ------- homeassistant/components/komfovent/const.py | 3 - .../components/komfovent/manifest.json | 9 - .../components/komfovent/strings.json | 22 -- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/komfovent/__init__.py | 1 - tests/components/komfovent/conftest.py | 14 -- .../components/komfovent/test_config_flow.py | 189 ------------------ 15 files changed, 454 deletions(-) delete mode 100644 homeassistant/components/komfovent/__init__.py delete mode 100644 homeassistant/components/komfovent/climate.py delete mode 100644 homeassistant/components/komfovent/config_flow.py delete mode 100644 homeassistant/components/komfovent/const.py delete mode 100644 homeassistant/components/komfovent/manifest.json delete mode 100644 homeassistant/components/komfovent/strings.json delete mode 100644 tests/components/komfovent/__init__.py delete mode 100644 tests/components/komfovent/conftest.py delete mode 100644 tests/components/komfovent/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 29b48e439ea..9e5044dff64 100644 --- a/.coveragerc +++ b/.coveragerc @@ -636,8 +636,6 @@ omit = homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/media_player.py homeassistant/components/kodi/notify.py - homeassistant/components/komfovent/__init__.py - homeassistant/components/komfovent/climate.py homeassistant/components/konnected/__init__.py homeassistant/components/konnected/panel.py homeassistant/components/konnected/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 6645a6f06a3..33587dec721 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -665,8 +665,6 @@ build.json @home-assistant/supervisor /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund /tests/components/kodi/ @OnFreund -/homeassistant/components/komfovent/ @ProstoSanja -/tests/components/komfovent/ @ProstoSanja /homeassistant/components/konnected/ @heythisisnate /tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm diff --git a/homeassistant/components/komfovent/__init__.py b/homeassistant/components/komfovent/__init__.py deleted file mode 100644 index 0366a429b21..00000000000 --- a/homeassistant/components/komfovent/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -"""The Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import DOMAIN - -PLATFORMS: list[Platform] = [Platform.CLIMATE] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Komfovent from a config entry.""" - host = entry.data[CONF_HOST] - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - _, credentials = komfovent_api.get_credentials(host, username, password) - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - raise ConfigEntryNotReady(f"Unable to connect to {host}: {result}") - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (credentials, settings) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/komfovent/climate.py b/homeassistant/components/komfovent/climate.py deleted file mode 100644 index 2e51fddf4f2..00000000000 --- a/homeassistant/components/komfovent/climate.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Ventilation Units from Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.components.climate import ( - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN - -HASS_TO_KOMFOVENT_MODES = { - HVACMode.COOL: komfovent_api.KomfoventModes.COOL, - HVACMode.HEAT_COOL: komfovent_api.KomfoventModes.HEAT_COOL, - HVACMode.OFF: komfovent_api.KomfoventModes.OFF, - HVACMode.AUTO: komfovent_api.KomfoventModes.AUTO, -} -KOMFOVENT_TO_HASS_MODES = {v: k for k, v in HASS_TO_KOMFOVENT_MODES.items()} - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Komfovent unit control.""" - credentials, settings = hass.data[DOMAIN][entry.entry_id] - async_add_entities([KomfoventDevice(credentials, settings)], True) - - -class KomfoventDevice(ClimateEntity): - """Representation of a ventilation unit.""" - - _attr_hvac_modes = list(HASS_TO_KOMFOVENT_MODES.keys()) - _attr_preset_modes = [mode.name for mode in komfovent_api.KomfoventPresets] - _attr_supported_features = ClimateEntityFeature.PRESET_MODE - _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True - _attr_name = None - - def __init__( - self, - credentials: komfovent_api.KomfoventCredentials, - settings: komfovent_api.KomfoventSettings, - ) -> None: - """Initialize the ventilation unit.""" - self._komfovent_credentials = credentials - self._komfovent_settings = settings - - self._attr_unique_id = settings.serial_number - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, settings.serial_number)}, - model=settings.model, - name=settings.name, - serial_number=settings.serial_number, - sw_version=settings.version, - manufacturer="Komfovent", - ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new target preset mode.""" - await komfovent_api.set_preset( - self._komfovent_credentials, - komfovent_api.KomfoventPresets[preset_mode], - ) - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - await komfovent_api.set_mode( - self._komfovent_credentials, HASS_TO_KOMFOVENT_MODES[hvac_mode] - ) - - async def async_update(self) -> None: - """Get the latest data.""" - result, status = await komfovent_api.get_unit_status( - self._komfovent_credentials - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS or not status: - self._attr_available = False - return - self._attr_available = True - self._attr_preset_mode = status.preset - self._attr_current_temperature = status.temp_extract - self._attr_hvac_mode = KOMFOVENT_TO_HASS_MODES[status.mode] diff --git a/homeassistant/components/komfovent/config_flow.py b/homeassistant/components/komfovent/config_flow.py deleted file mode 100644 index fb5390a30c6..00000000000 --- a/homeassistant/components/komfovent/config_flow.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Config flow for Komfovent integration.""" -from __future__ import annotations - -import logging -from typing import Any - -import komfovent_api -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER = "user" -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Optional(CONF_USERNAME, default="user"): str, - vol.Required(CONF_PASSWORD): str, - } -) - -ERRORS_MAP = { - komfovent_api.KomfoventConnectionResult.NOT_FOUND: "cannot_connect", - komfovent_api.KomfoventConnectionResult.UNAUTHORISED: "invalid_auth", - komfovent_api.KomfoventConnectionResult.INVALID_INPUT: "invalid_input", -} - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Komfovent.""" - - VERSION = 1 - - def __return_error( - self, result: komfovent_api.KomfoventConnectionResult - ) -> FlowResult: - return self.async_show_form( - step_id=STEP_USER, - data_schema=STEP_USER_DATA_SCHEMA, - errors={"base": ERRORS_MAP.get(result, "unknown")}, - ) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id=STEP_USER, data_schema=STEP_USER_DATA_SCHEMA - ) - - conf_host = user_input[CONF_HOST] - conf_username = user_input[CONF_USERNAME] - conf_password = user_input[CONF_PASSWORD] - - result, credentials = komfovent_api.get_credentials( - conf_host, conf_username, conf_password - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - await self.async_set_unique_id(settings.serial_number) - self._abort_if_unique_id_configured() - - return self.async_create_entry(title=settings.name, data=user_input) diff --git a/homeassistant/components/komfovent/const.py b/homeassistant/components/komfovent/const.py deleted file mode 100644 index a7881a58c41..00000000000 --- a/homeassistant/components/komfovent/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Komfovent integration.""" - -DOMAIN = "komfovent" diff --git a/homeassistant/components/komfovent/manifest.json b/homeassistant/components/komfovent/manifest.json deleted file mode 100644 index cbe00ef8dc5..00000000000 --- a/homeassistant/components/komfovent/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "komfovent", - "name": "Komfovent", - "codeowners": ["@ProstoSanja"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/komfovent", - "iot_class": "local_polling", - "requirements": ["komfovent-api==0.0.3"] -} diff --git a/homeassistant/components/komfovent/strings.json b/homeassistant/components/komfovent/strings.json deleted file mode 100644 index 074754c1fe0..00000000000 --- a/homeassistant/components/komfovent/strings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "host": "[%key:common::config_flow::data::host%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_input": "Failed to parse provided hostname", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - } - } -} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 30c884249a9..e83a2a74405 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -247,7 +247,6 @@ FLOWS = { "kmtronic", "knx", "kodi", - "komfovent", "konnected", "kostal_plenticore", "kraken", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 394b40ac630..56b0fa4ef9d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2892,12 +2892,6 @@ "config_flow": true, "iot_class": "local_push" }, - "komfovent": { - "name": "Komfovent", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, "konnected": { "name": "Konnected.io", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 8ed260e654f..11cad3a4536 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1131,9 +1131,6 @@ kiwiki-client==0.1.1 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 917da82ee30..badde74823a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -888,9 +888,6 @@ kegtron-ble==0.4.0 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/komfovent/__init__.py b/tests/components/komfovent/__init__.py deleted file mode 100644 index e5492a52327..00000000000 --- a/tests/components/komfovent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Komfovent integration.""" diff --git a/tests/components/komfovent/conftest.py b/tests/components/komfovent/conftest.py deleted file mode 100644 index d9cb0950c74..00000000000 --- a/tests/components/komfovent/conftest.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Common fixtures for the Komfovent tests.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch - -import pytest - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.komfovent.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry diff --git a/tests/components/komfovent/test_config_flow.py b/tests/components/komfovent/test_config_flow.py deleted file mode 100644 index 008d92e36a3..00000000000 --- a/tests/components/komfovent/test_config_flow.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Test the Komfovent config flow.""" -from unittest.mock import AsyncMock, patch - -import komfovent_api -import pytest - -from homeassistant import config_entries -from homeassistant.components.komfovent.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType - -from tests.common import MockConfigEntry - - -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test flow completes as expected.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_authenticating( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow authentication step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_device_info( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow device info download step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_device_already_exists( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test device is not added when it already exists.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - unique_id="test-uid", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.ABORT - assert final_result["reason"] == "already_configured" - - -async def __test_normal_flow(hass: HomeAssistant, flow_id: str) -> FlowResult: - """Test flow completing as expected, no matter what happened before.""" - - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventSettings("test-name", None, None, "test-uid"), - ), - ): - final_result = await hass.config_entries.flow.async_configure( - flow_id, - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - return final_result From d597cfec4960cbcae80ead57f7e21c9ac75cfa94 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:45:18 +0100 Subject: [PATCH 309/643] Improve decorator type annotations (#104821) --- homeassistant/components/guardian/__init__.py | 8 +++++--- homeassistant/components/rainmachine/__init__.py | 13 ++++++++++--- homeassistant/components/simplisafe/__init__.py | 6 ++++-- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index d7a9fe4e836..bd2cb8c96de 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import cast +from typing import Any, cast from aioguardian import Client from aioguardian.errors import GuardianError @@ -170,7 +170,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def call_with_data(func: Callable) -> Callable: + def call_with_data( + func: Callable[[ServiceCall, GuardianData], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Hydrate a service call with the appropriate GuardianData object.""" async def wrapper(call: ServiceCall) -> None: diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index c29154a941c..fde9b945e53 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta from functools import partial, wraps @@ -326,10 +326,17 @@ async def async_setup_entry( # noqa: C901 entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - def call_with_controller(update_programs_and_zones: bool = True) -> Callable: + def call_with_controller( + update_programs_and_zones: bool = True, + ) -> Callable[ + [Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]]], + Callable[[ServiceCall], Coroutine[Any, Any, None]], + ]: """Hydrate a service call with the appropriate controller.""" - def decorator(func: Callable) -> Callable[..., Awaitable]: + def decorator( + func: Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define the decorator.""" @wraps(func) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 7b57fa1fc32..b1bd2c8e9d6 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable +from collections.abc import Callable, Coroutine, Iterable from datetime import timedelta from typing import Any, cast @@ -336,7 +336,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def extract_system(func: Callable) -> Callable: + def extract_system( + func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct system for a service call.""" async def wrapper(call: ServiceCall) -> None: From 9bdb7134087a4d61f6e5ac3c7c2df382ece6bbce Mon Sep 17 00:00:00 2001 From: Mappenhei <124632181+Mappenhei@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:45:52 +0100 Subject: [PATCH 310/643] Add Humidity device class to LaCross humidity sensor (#104814) --- homeassistant/components/lacrosse/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index 7355a60f5f0..40d38da55eb 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -209,7 +209,7 @@ class LaCrosseHumidity(LaCrosseSensor): _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT - _attr_icon = "mdi:water-percent" + _attr_device_class = SensorDeviceClass.HUMIDITY @property def native_value(self) -> int | None: From 8e2f4a347c9043d8aef52ed6213ca71f0d8ccdc7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Nov 2023 18:46:18 +0100 Subject: [PATCH 311/643] Address late review for the host field description in Shelly integration (#104815) Co-authored-by: Franck Nijhof --- homeassistant/components/shelly/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 49c66a56459..9230ae605e0 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -8,7 +8,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of the Shelly device to control." + "host": "The hostname or IP address of the Shelly device to connect to." } }, "credentials": { From 6ffc298986fc55cb9c1e8bf13ff5f392e036ea8e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:47:18 +0100 Subject: [PATCH 312/643] Improve decorator type annotations [matter] (#104822) --- homeassistant/components/matter/api.py | 31 ++++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index 7b4b7d35b7f..227d0c73e89 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -1,9 +1,9 @@ """Handle websocket api for Matter.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from functools import wraps -from typing import Any +from typing import Any, Concatenate, ParamSpec from matter_server.common.errors import MatterError import voluptuous as vol @@ -15,6 +15,8 @@ from homeassistant.core import HomeAssistant, callback from .adapter import MatterAdapter from .helpers import get_matter +_P = ParamSpec("_P") + ID = "id" TYPE = "type" @@ -28,12 +30,19 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_set_wifi_credentials) -def async_get_matter_adapter(func: Callable) -> Callable: +def async_get_matter_adapter( + func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], MatterAdapter], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate function to get the MatterAdapter.""" @wraps(func) async def _get_matter( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide the Matter client to the function.""" matter = get_matter(hass) @@ -43,7 +52,15 @@ def async_get_matter_adapter(func: Callable) -> Callable: return _get_matter -def async_handle_failed_command(func: Callable) -> Callable: +def async_handle_failed_command( + func: Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], + ], +) -> Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], +]: """Decorate function to handle MatterError and send relevant error.""" @wraps(func) @@ -51,8 +68,8 @@ def async_handle_failed_command(func: Callable) -> Callable: hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - *args: Any, - **kwargs: Any, + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: """Handle MatterError within function and send relevant error.""" try: From 46ba62a3c0dcbe575864005a90ede7a2eaec1a15 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:47:58 +0100 Subject: [PATCH 313/643] Improve decorator type annotations [sabnzbd] (#104823) --- homeassistant/components/sabnzbd/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index babdbc573bd..b1d118e6f75 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -1,8 +1,9 @@ """Support for monitoring an SABnzbd NZB client.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import logging +from typing import Any from pysabnzbd import SabnzbdApiException import voluptuous as vol @@ -189,7 +190,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_device_identifiers(hass, entry) @callback - def extract_api(func: Callable) -> Callable: + def extract_api( + func: Callable[[ServiceCall, SabnzbdApiData], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct api for a service call.""" async def wrapper(call: ServiceCall) -> None: From 2496c275c8cdbd3c1f3bb2c5676cf04021b7c61b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:50:31 +0100 Subject: [PATCH 314/643] Improve decorator type annotations [core] (#104826) --- homeassistant/components/frontend/storage.py | 13 ++++++++++--- homeassistant/components/hassio/repairs.py | 10 ++++++++-- homeassistant/helpers/schema_config_entry_flow.py | 14 ++++++++++++-- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 82f169dc6c9..91646dcb745 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -1,7 +1,7 @@ """API for persistent storage for the frontend.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from functools import wraps from typing import Any @@ -50,12 +50,19 @@ async def async_user_store( return store, data[user_id] -def with_store(orig_func: Callable) -> Callable: +def with_store( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], Store, dict[str, Any]], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate function to provide data.""" @wraps(orig_func) async def with_store_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" user_id = connection.user.id diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 8337405641c..fcfe23dda6e 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -1,6 +1,7 @@ """Repairs implementation for supervisor integration.""" +from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from types import MethodType from typing import Any @@ -116,7 +117,12 @@ class SupervisorIssueRepairFlow(RepairsFlow): return self.async_create_entry(data={}) @staticmethod - def _async_step(suggestion: Suggestion) -> Callable: + def _async_step( + suggestion: Suggestion, + ) -> Callable[ + [SupervisorIssueRepairFlow, dict[str, str] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler for a suggestion.""" async def _async_step( diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index dcf7f07bf6b..2bbad0ed63a 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -331,7 +331,12 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): return cls.options_flow is not None @staticmethod - def _async_step(step_id: str) -> Callable: + def _async_step( + step_id: str, + ) -> Callable[ + [SchemaConfigFlowHandler, dict[str, Any] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler.""" async def _async_step( @@ -421,7 +426,12 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): setattr(self, "async_setup_preview", async_setup_preview) @staticmethod - def _async_step(step_id: str) -> Callable: + def _async_step( + step_id: str, + ) -> Callable[ + [SchemaConfigFlowHandler, dict[str, Any] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler.""" async def _async_step( From fed8e5e8739f0a9a3eb94d90b66ff2dafaf81ab8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Nov 2023 18:51:23 +0100 Subject: [PATCH 315/643] Always create APCUPS device (#104716) --- homeassistant/components/apcupsd/coordinator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index 321da56095a..ae4c94a9382 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -9,6 +9,7 @@ from typing import Final from apcaccess import status +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -32,6 +33,8 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): updates from the server. """ + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: """Initialize the data object.""" super().__init__( @@ -70,13 +73,10 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): return self.data.get("SERIALNO") @property - def device_info(self) -> DeviceInfo | None: + def device_info(self) -> DeviceInfo: """Return the DeviceInfo of this APC UPS, if serial number is available.""" - if not self.ups_serial_no: - return None - return DeviceInfo( - identifiers={(DOMAIN, self.ups_serial_no)}, + identifiers={(DOMAIN, self.ups_serial_no or self.config_entry.entry_id)}, model=self.ups_model, manufacturer="APC", name=self.ups_name if self.ups_name else "APC UPS", From ca9d58c442896aaa3c9cf0b00633c1b2933e5722 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 30 Nov 2023 19:06:36 +0100 Subject: [PATCH 316/643] Improve decorator type annotations [sensibo] (#104824) --- homeassistant/components/sensibo/entity.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 9f20c051576..0a60fc4a85d 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -23,22 +23,24 @@ def async_handle_api_call( ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]: """Decorate api calls.""" - async def wrap_api_call(*args: Any, **kwargs: Any) -> None: + async def wrap_api_call(entity: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap services for api calls.""" res: bool = False try: async with asyncio.timeout(TIMEOUT): - res = await function(*args, **kwargs) + res = await function(entity, *args, **kwargs) except SENSIBO_ERRORS as err: raise HomeAssistantError from err - LOGGER.debug("Result %s for entity %s with arguments %s", res, args[0], kwargs) - entity: SensiboDeviceBaseEntity = args[0] + LOGGER.debug("Result %s for entity %s with arguments %s", res, entity, kwargs) if res is not True: raise HomeAssistantError(f"Could not execute service for {entity.name}") - if kwargs.get("key") is not None and kwargs.get("value") is not None: - setattr(entity.device_data, kwargs["key"], kwargs["value"]) - LOGGER.debug("Debug check key %s is now %s", kwargs["key"], kwargs["value"]) + if ( + isinstance(key := kwargs.get("key"), str) + and (value := kwargs.get("value")) is not None + ): + setattr(entity.device_data, key, value) + LOGGER.debug("Debug check key %s is now %s", key, value) entity.async_write_ha_state() await entity.coordinator.async_request_refresh() From f1e8c1c7ee983e94f7f1ba4351888787dd7e8367 Mon Sep 17 00:00:00 2001 From: Sergiy Maysak Date: Thu, 30 Nov 2023 13:14:46 +0200 Subject: [PATCH 317/643] Fix wirelesstag unique_id to use uuid instead of tag_id (#104394) Co-authored-by: Robert Resch --- .../components/wirelesstag/__init__.py | 16 ++++++++++ .../components/wirelesstag/binary_sensor.py | 12 ++++--- .../components/wirelesstag/sensor.py | 18 +++++++---- .../components/wirelesstag/switch.py | 32 +++++++++++-------- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 06fbfa3621e..f95337dbaf4 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -5,6 +5,7 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from wirelesstagpy import WirelessTags from wirelesstagpy.exceptions import WirelessTagsException +from wirelesstagpy.sensortag import SensorTag from homeassistant.components import persistent_notification from homeassistant.const import ( @@ -17,6 +18,7 @@ from homeassistant.const import ( UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity @@ -126,6 +128,20 @@ class WirelessTagPlatform: self.api.start_monitoring(push_callback) +def async_migrate_unique_id(hass: HomeAssistant, tag: SensorTag, domain: str, key: str): + """Migrate old unique id to new one with use of tag's uuid.""" + registry = er.async_get(hass) + new_unique_id = f"{tag.uuid}_{key}" + + if registry.async_get_entity_id(domain, DOMAIN, new_unique_id): + return + + old_unique_id = f"{tag.tag_id}_{key}" + if entity_id := registry.async_get_entity_id(domain, DOMAIN, old_unique_id): + _LOGGER.debug("Updating unique id for %s %s", key, entity_id) + registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Wireless Sensor Tag component.""" conf = config[DOMAIN] diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 711c2987735..64a1097bcab 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON +from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -15,6 +15,7 @@ from . import ( DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_BINARY_EVENT_UPDATE, WirelessTagBaseSensor, + async_migrate_unique_id, ) # On means in range, Off means out of range @@ -72,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the platform for a WirelessTags.""" @@ -87,9 +88,10 @@ def setup_platform( allowed_sensor_types = tag.supported_binary_events_types for sensor_type in config[CONF_MONITORED_CONDITIONS]: if sensor_type in allowed_sensor_types: + async_migrate_unique_id(hass, tag, Platform.BINARY_SENSOR, sensor_type) sensors.append(WirelessTagBinarySensor(platform, tag, sensor_type)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): @@ -100,7 +102,7 @@ class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): super().__init__(api, tag) self._sensor_type = sensor_type self._name = f"{self._tag.name} {self.event.human_readable_name}" - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index fd9a7898f92..8ae20031723 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -12,14 +12,19 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_TAG_UPDATE, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + SIGNAL_TAG_UPDATE, + WirelessTagBaseSensor, + async_migrate_unique_id, +) _LOGGER = logging.getLogger(__name__) @@ -68,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" @@ -83,9 +88,10 @@ def setup_platform( if key not in tag.allowed_sensor_types: continue description = SENSOR_TYPES[key] + async_migrate_unique_id(hass, tag, Platform.SENSOR, description.key) sensors.append(WirelessTagSensor(platform, tag, description)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): @@ -100,7 +106,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): self._sensor_type = description.key self.entity_description = description self._name = self._tag.name - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" # I want to see entity_id as: # sensor.wirelesstag_bedroom_temperature diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index df0f72aca18..7f4008623b1 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -10,13 +10,17 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as WIRELESSTAG_DOMAIN, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + WirelessTagBaseSensor, + async_migrate_unique_id, +) SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -52,10 +56,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switches for a Wireless Sensor Tags.""" @@ -63,15 +67,17 @@ def setup_platform( tags = platform.load_tags() monitored_conditions = config[CONF_MONITORED_CONDITIONS] - entities = [ - WirelessTagSwitch(platform, tag, description) - for tag in tags.values() - for description in SWITCH_TYPES - if description.key in monitored_conditions - and description.key in tag.allowed_monitoring_types - ] + entities = [] + for tag in tags.values(): + for description in SWITCH_TYPES: + if ( + description.key in monitored_conditions + and description.key in tag.allowed_monitoring_types + ): + async_migrate_unique_id(hass, tag, Platform.SWITCH, description.key) + entities.append(WirelessTagSwitch(platform, tag, description)) - add_entities(entities, True) + async_add_entities(entities, True) class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): @@ -82,7 +88,7 @@ class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): super().__init__(api, tag) self.entity_description = description self._name = f"{self._tag.name} {description.name}" - self._attr_unique_id = f"{self.tag_id}_{description.key}" + self._attr_unique_id = f"{self._uuid}_{description.key}" def turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" From 4acea82ca13d4b60148d7ce03a24e29a9e1f8bb4 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 29 Nov 2023 21:54:05 +0100 Subject: [PATCH 318/643] Fix Philips TV none recordings_list (#104665) Correct for missing recordings list in api client. --------- Co-authored-by: Joakim Plate --- .../components/philips_js/binary_sensor.py | 2 ++ .../philips_js/test_binary_sensor.py | 31 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index 78aa9f17b05..1e6c1241aea 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -66,6 +66,8 @@ async def async_setup_entry( def _check_for_recording_entry(api: PhilipsTV, entry: str, value: str) -> bool: """Return True if at least one specified value is available within entry of list.""" + if api.recordings_list is None: + return False for rec in api.recordings_list["recordings"]: if rec.get(entry) == value: return True diff --git a/tests/components/philips_js/test_binary_sensor.py b/tests/components/philips_js/test_binary_sensor.py index d11f3fe22f1..01233706d07 100644 --- a/tests/components/philips_js/test_binary_sensor.py +++ b/tests/components/philips_js/test_binary_sensor.py @@ -1,7 +1,7 @@ """The tests for philips_js binary_sensor.""" import pytest -from homeassistant.const import STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from . import MOCK_NAME, MOCK_RECORDINGS_LIST @@ -32,7 +32,16 @@ async def mock_tv_api_valid(mock_tv): return mock_tv -async def test_recordings_list_invalid( +@pytest.fixture +async def mock_tv_recordings_list_unavailable(mock_tv): + """Set up a valid mock_tv with should create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 6 + mock_tv.recordings_list = None + return mock_tv + + +async def test_recordings_list_api_invalid( mock_tv_api_invalid, mock_config_entry, hass: HomeAssistant ) -> None: """Test if sensors are not created if mock_tv is invalid.""" @@ -54,7 +63,21 @@ async def test_recordings_list_valid( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) state = hass.states.get(ID_RECORDING_AVAILABLE) - assert state.state is STATE_ON + assert state.state == STATE_ON state = hass.states.get(ID_RECORDING_ONGOING) - assert state.state is STATE_ON + assert state.state == STATE_ON + + +async def test_recordings_list_unavailable( + mock_tv_recordings_list_unavailable, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are created correctly.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state.state == STATE_OFF + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state.state == STATE_OFF From 6f45fafc11507302a65e2212159bb9a60c70a499 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 29 Nov 2023 17:13:05 -0500 Subject: [PATCH 319/643] Bump pynws to 1.6.0 (#104679) --- homeassistant/components/nws/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 05194d85a26..4006a145db4 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], "quality_scale": "platinum", - "requirements": ["pynws==1.5.1"] + "requirements": ["pynws==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ec0e5f232e2..d19df8c1c9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1922,7 +1922,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75947854c7f..5bf8f26c874 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1451,7 +1451,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 From 78f1c0cb803ee444a392f7ef17522615e1c7aa1c Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:12:39 +0100 Subject: [PATCH 320/643] Axis: add host and user name field description (#104693) --- homeassistant/components/axis/strings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 47a25b542a7..8c302dba201 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -3,12 +3,16 @@ "flow_title": "{name} ({host})", "step": { "user": { - "title": "Set up Axis device", + "description": "Set up an Axis device", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Axis device.", + "username": "The user name you set up on your Axis device. It is recommended to create a user specifically for Home Assistant." } } }, From 5f549649de60a7f69053587c81f8885c8c105890 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 30 Nov 2023 01:38:33 +0100 Subject: [PATCH 321/643] Update initial translation for ViCare water heater entity (#104696) --- homeassistant/components/vicare/strings.json | 4 ++-- homeassistant/components/vicare/water_heater.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index e9ee272edd8..47ee60b2ea8 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -283,8 +283,8 @@ } }, "water_heater": { - "water": { - "name": "Water" + "domestic_hot_water": { + "name": "Domestic hot water" } } }, diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 036ced5ee55..66a90ca065b 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -64,13 +64,13 @@ def _build_entities( api: PyViCareDevice, device_config: PyViCareDeviceConfig, ) -> list[ViCareWater]: - """Create ViCare water entities for a device.""" + """Create ViCare domestic hot water entities for a device.""" return [ ViCareWater( api, circuit, device_config, - "water", + "domestic_hot_water", ) for circuit in get_circuits(api) ] @@ -81,7 +81,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the ViCare climate platform.""" + """Set up the ViCare water heater platform.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] From 34c65749e2d3cc4e52d2979d2749fbca94e56845 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Nov 2023 23:16:58 +0100 Subject: [PATCH 322/643] Revert "Add proj dependency to our wheels builder (#104699)" (#104704) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 9d16954cd09..3b23f1b5b05 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -199,7 +199,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;proj-dev;proj-util;uchardet-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From 90bcad31b5c2694b6765976ae19c96b0c58eee99 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Nov 2023 18:31:27 -0600 Subject: [PATCH 323/643] Skip TTS when text is empty (#104741) Co-authored-by: Paulus Schoutsen --- .../components/assist_pipeline/pipeline.py | 59 ++++++++------- .../components/esphome/voice_assistant.py | 22 +++--- homeassistant/components/voip/voip.py | 15 ++-- .../snapshots/test_websocket.ambr | 27 +++++++ .../assist_pipeline/test_websocket.py | 51 +++++++++++++ .../esphome/test_voice_assistant.py | 22 ++++++ tests/components/voip/test_voip.py | 72 +++++++++++++++++++ 7 files changed, 225 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 1eb32a9dc3f..4f2a9a8d99b 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1024,39 +1024,38 @@ class PipelineRun: ) ) - try: - # Synthesize audio and get URL - tts_media_id = tts_generate_media_source_id( - self.hass, - tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, - ) - except Exception as src_error: - _LOGGER.exception("Unexpected error during text-to-speech") - raise TextToSpeechError( - code="tts-failed", - message="Unexpected error during text-to-speech", - ) from src_error + if tts_input := tts_input.strip(): + try: + # Synthesize audio and get URL + tts_media_id = tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.tts_engine, + language=self.pipeline.tts_language, + options=self.tts_options, + ) + tts_media = await media_source.async_resolve_media( + self.hass, + tts_media_id, + None, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during text-to-speech") + raise TextToSpeechError( + code="tts-failed", + message="Unexpected error during text-to-speech", + ) from src_error - _LOGGER.debug("TTS result %s", tts_media) + _LOGGER.debug("TTS result %s", tts_media) + tts_output = { + "media_id": tts_media_id, + **asdict(tts_media), + } + else: + tts_output = {} self.process_event( - PipelineEvent( - PipelineEventType.TTS_END, - { - "tts_output": { - "media_id": tts_media_id, - **asdict(tts_media), - } - }, - ) + PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) ) return tts_media.url diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 68ed98aa789..de6b521d980 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -186,16 +186,22 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): data_to_send = {"text": event.data["tts_input"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: assert event.data is not None - path = event.data["tts_output"]["url"] - url = async_process_play_media_url(self.hass, path) - data_to_send = {"url": url} + tts_output = event.data["tts_output"] + if tts_output: + path = tts_output["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} - if self.device_info.voice_assistant_version >= 2: - media_id = event.data["tts_output"]["media_id"] - self._tts_task = self.hass.async_create_background_task( - self._send_tts(media_id), "esphome_voice_assistant_tts" - ) + if self.device_info.voice_assistant_version >= 2: + media_id = tts_output["media_id"] + self._tts_task = self.hass.async_create_background_task( + self._send_tts(media_id), "esphome_voice_assistant_tts" + ) + else: + self._tts_done.set() else: + # Empty TTS response + data_to_send = {} self._tts_done.set() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: assert event.data is not None diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 74bc94e7dc5..11f70c631f1 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -389,11 +389,16 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self._conversation_id = event.data["intent_output"]["conversation_id"] elif event.type == PipelineEventType.TTS_END: # Send TTS audio to caller over RTP - media_id = event.data["tts_output"]["media_id"] - self.hass.async_create_background_task( - self._send_tts(media_id), - "voip_pipeline_tts", - ) + tts_output = event.data["tts_output"] + if tts_output: + media_id = tts_output["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), + "voip_pipeline_tts", + ) + else: + # Empty TTS response + self._tts_done.set() elif event.type == PipelineEventType.ERROR: # Play error tone instead of wait for TTS self._pipeline_error = True diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 1f625528806..072b1ff730a 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -650,6 +650,33 @@ 'message': 'Timeout running pipeline', }) # --- +# name: test_pipeline_empty_tts_output + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': None, + 'timeout': 300, + }), + }) +# --- +# name: test_pipeline_empty_tts_output.1 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': '', + 'voice': 'james_earl_jones', + }) +# --- +# name: test_pipeline_empty_tts_output.2 + dict({ + 'tts_output': dict({ + }), + }) +# --- +# name: test_pipeline_empty_tts_output.3 + None +# --- # name: test_stt_provider_missing dict({ 'language': 'en', diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 931b31dd77b..0e2a3ad538c 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2452,3 +2452,54 @@ async def test_device_capture_queue_full( assert msg["event"] == snapshot assert msg["event"]["type"] == "end" assert msg["event"]["overflow"] + + +async def test_pipeline_empty_tts_output( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with a empty text-to-speech text.""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": { + "text": "", + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + assert not msg["event"]["data"]["tts_output"] + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index ca74c99f0cd..38a33bfdec2 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -337,6 +337,28 @@ async def test_send_tts_called( mock_send_tts.assert_called_with(_TEST_MEDIA_ID) +async def test_send_tts_not_called_when_empty( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server with a v1/v2 device doesn't call _send_tts when the output is empty.""" + with patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts" + ) as mock_send_tts: + voice_assistant_udp_server_v1._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + async def test_send_tts( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 692896c6dfa..dbb848f3b9d 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -528,3 +528,75 @@ async def test_tts_wrong_wav_format( # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(1): await done.wait() + + +async def test_empty_tts_output( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will not stream when output is empty.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Empty TTS output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {}}, + ) + ) + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.PipelineRtpDatagramProtocol._send_tts", + ) as mock_send_tts: + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to finish + async with asyncio.timeout(1): + await rtp_protocol._tts_done.wait() + + mock_send_tts.assert_not_called() From f366b37c529460cf473e056c080d12afd20999d3 Mon Sep 17 00:00:00 2001 From: Daniel Gangl <31815106+killer0071234@users.noreply.github.com> Date: Thu, 30 Nov 2023 01:31:39 +0100 Subject: [PATCH 324/643] Bump zamg to 0.3.3 (#104756) --- homeassistant/components/zamg/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index df17672231e..f83e38002b8 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", "iot_class": "cloud_polling", - "requirements": ["zamg==0.3.0"] + "requirements": ["zamg==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index d19df8c1c9a..de84d8fb420 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2807,7 +2807,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5bf8f26c874..f2241b1c5d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2105,7 +2105,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zeroconf zeroconf==0.127.0 From b4907800a9c673672caa0d4e0c8a8957e7f6a1b6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Nov 2023 00:32:12 +0100 Subject: [PATCH 325/643] Debug level logging for DSMR migration code (#104757) --- homeassistant/components/dsmr/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 487f996ac1f..f56e2c3ed33 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -456,12 +456,12 @@ def rename_old_gas_to_mbus( device_id=mbus_device_id, ) except ValueError: - LOGGER.warning( + LOGGER.debug( "Skip migration of %s because it already exists", entity.entity_id, ) else: - LOGGER.info( + LOGGER.debug( "Migrated entity %s from unique id %s to %s", entity.entity_id, entity.unique_id, From 4b22551af12e66dd7fff9b69a6845737a22247b7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 21:11:38 -0800 Subject: [PATCH 326/643] Fix bug in rainbird device ids that are int serial numbers (#104768) --- homeassistant/components/rainbird/__init__.py | 2 +- tests/components/rainbird/test_init.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index c149c993acb..e5731dc08fe 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -183,7 +183,7 @@ def _async_fix_device_id( device_entry_map = {} migrations = {} for device_entry in device_entries: - unique_id = next(iter(device_entry.identifiers))[1] + unique_id = str(next(iter(device_entry.identifiers))[1]) device_entry_map[unique_id] = device_entry if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: migrations[unique_id] = f"{mac_address}{suffix}" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 7048e1d63f4..00cbefc6556 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -239,6 +239,14 @@ async def test_fix_unique_id_duplicate( f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", f"{MAC_ADDRESS_UNIQUE_ID}-1", ), + ( + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + ), ("0", 0, "0", "0", MAC_ADDRESS_UNIQUE_ID, MAC_ADDRESS_UNIQUE_ID), ( "0", @@ -268,6 +276,7 @@ async def test_fix_unique_id_duplicate( ids=( "serial-number", "serial-number-with-suffix", + "serial-number-int", "zero-serial", "zero-serial-suffix", "new-format", From 816e524457158a38a17999f2e03805f465d725d9 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:47:37 +0100 Subject: [PATCH 327/643] Broadlink, BSB-Lan: add host field description (#104770) --- homeassistant/components/broadlink/strings.json | 5 ++++- homeassistant/components/bsblan/strings.json | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index 87567bcb7b1..335984d1ebe 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -3,10 +3,13 @@ "flow_title": "{name} ({model} at {host})", "step": { "user": { - "title": "Connect to the device", + "description": "Connect to the device", "data": { "host": "[%key:common::config_flow::data::host%]", "timeout": "Timeout" + }, + "data_description": { + "host": "The hostname or IP address of your Broadlink device." } }, "auth": { diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 0693f3fb8ea..689d1f893d3 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -11,6 +11,9 @@ "passkey": "Passkey string", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your BSB-Lan device." } } }, From fe544f670fe7e3f435dd19049f85d20df601eb25 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:47:17 +0100 Subject: [PATCH 328/643] Comelit, Coolmaster: add host field description (#104771) --- homeassistant/components/comelit/strings.json | 3 +++ homeassistant/components/coolmaster/strings.json | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 730674e913a..73c2c7d00c6 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "host": "The hostname or IP address of your Comelit device." } } }, diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index 7baa6444c1d..17deab306df 100644 --- a/homeassistant/components/coolmaster/strings.json +++ b/homeassistant/components/coolmaster/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up your CoolMasterNet connection details.", + "description": "Set up your CoolMasterNet connection details.", "data": { "host": "[%key:common::config_flow::data::host%]", "off": "Can be turned off", @@ -12,6 +12,9 @@ "dry": "Support dry mode", "fan_only": "Support fan only mode", "swing_support": "Control swing mode" + }, + "data_description": { + "host": "The hostname or IP address of your CoolMasterNet device." } } }, From 4eec48de51bd5720aa649bc628b37d095eae5950 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:24:21 +0100 Subject: [PATCH 329/643] Deconz to DoorBird: add host field description (#104772) * Deconz to DoorBird: add host field description * Update homeassistant/components/deconz/strings.json Co-authored-by: Robert Svensson --------- Co-authored-by: Robert Svensson --- homeassistant/components/deconz/strings.json | 5 ++++- homeassistant/components/deluge/strings.json | 3 +++ homeassistant/components/directv/strings.json | 3 +++ homeassistant/components/dlink/strings.json | 1 + homeassistant/components/doorbird/strings.json | 5 ++++- 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index e32ab875c28..c06a07e6ce5 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -11,11 +11,14 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your deCONZ host." } }, "link": { "title": "Link with deCONZ", - "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button" + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Press \"Authenticate app\" button" }, "hassio_confirm": { "title": "deCONZ Zigbee gateway via Home Assistant add-on", diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index e0266d004e2..52706f39894 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", "web_port": "Web port (for visiting service)" + }, + "data_description": { + "host": "The hostname or IP address of your Deluge device." } } }, diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json index 8ed52cd3632..2c30e3db85c 100644 --- a/homeassistant/components/directv/strings.json +++ b/homeassistant/components/directv/strings.json @@ -8,6 +8,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your DirectTV device." } } }, diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index 8c60d59fa6b..9f21a9571e9 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -9,6 +9,7 @@ "use_legacy_protocol": "Use legacy protocol" }, "data_description": { + "host": "The hostname or IP address of your D-Link device", "password": "Default: PIN code on the back." } }, diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index ceaf1a891ee..c851de379d4 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -17,8 +17,11 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "host": "[%key:common::config_flow::data::host%]", - "name": "Device Name", + "name": "Device name", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your DoorBird device." } } }, From c3566db339f3b14e6951fd41b8572f8057f02b18 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 09:43:42 +0100 Subject: [PATCH 330/643] Dremel to Duotecno: add host field description (#104776) Co-authored-by: Franck Nijhof --- homeassistant/components/dremel_3d_printer/strings.json | 3 +++ homeassistant/components/dunehd/strings.json | 3 +++ homeassistant/components/duotecno/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json index 0016b8f2bca..9f6870b57f6 100644 --- a/homeassistant/components/dremel_3d_printer/strings.json +++ b/homeassistant/components/dremel_3d_printer/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dremel 3D printer." } } }, diff --git a/homeassistant/components/dunehd/strings.json b/homeassistant/components/dunehd/strings.json index f7e12b39f16..7d60a720a98 100644 --- a/homeassistant/components/dunehd/strings.json +++ b/homeassistant/components/dunehd/strings.json @@ -5,6 +5,9 @@ "description": "Ensure that your player is turned on.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dune HD device." } } }, diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index 93a545d31dc..a5585c3dd2c 100644 --- a/homeassistant/components/duotecno/strings.json +++ b/homeassistant/components/duotecno/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Duotecno device." } } }, From b6b2cf194d31fae07eea32d604736473b07d7aeb Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 09:44:18 +0100 Subject: [PATCH 331/643] Ecoforest to Emonitor: add host field description (#104778) --- homeassistant/components/ecoforest/strings.json | 3 +++ homeassistant/components/elgato/strings.json | 3 +++ homeassistant/components/emonitor/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index d1767be5cda..1094e10ada3 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ecoforest device." } } }, diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index e6b16215793..6e1031c8ddf 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Elgato device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/emonitor/strings.json b/homeassistant/components/emonitor/strings.json index 675db107935..08ffe030890 100644 --- a/homeassistant/components/emonitor/strings.json +++ b/homeassistant/components/emonitor/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your SiteSage Emonitor device." } }, "confirm": { From e1504759fed7bfbf497cf1e77291adf0ab796e20 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:25:04 +0100 Subject: [PATCH 332/643] Enphase to Evil: add host field description (#104779) Co-authored-by: Franck Nijhof --- homeassistant/components/enphase_envoy/strings.json | 3 +++ homeassistant/components/epson/strings.json | 3 +++ homeassistant/components/evil_genius_labs/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 94cf9233745..fe32002e6b2 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Enphase Envoy gateway." } } }, diff --git a/homeassistant/components/epson/strings.json b/homeassistant/components/epson/strings.json index 4e3780322e9..94544c32d1d 100644 --- a/homeassistant/components/epson/strings.json +++ b/homeassistant/components/epson/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Epson projector." } } }, diff --git a/homeassistant/components/evil_genius_labs/strings.json b/homeassistant/components/evil_genius_labs/strings.json index 790e9a69c7f..123d164444d 100644 --- a/homeassistant/components/evil_genius_labs/strings.json +++ b/homeassistant/components/evil_genius_labs/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Evil Genius Labs device." } } }, From 40c7432e8a879bb1846c91d5835031f1ae37bfa3 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 11:59:20 +0100 Subject: [PATCH 333/643] FiveM to Foscam: add host field description (#104782) --- homeassistant/components/fivem/strings.json | 3 +++ homeassistant/components/flo/strings.json | 3 +++ homeassistant/components/foscam/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json index 2ffb401f8c0..abdef61fb28 100644 --- a/homeassistant/components/fivem/strings.json +++ b/homeassistant/components/fivem/strings.json @@ -6,6 +6,9 @@ "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your FiveM server." } } }, diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index 627f562be7e..3444911fbd4 100644 --- a/homeassistant/components/flo/strings.json +++ b/homeassistant/components/flo/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Flo device." } } }, diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 35964ee4546..de22006b274 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "rtsp_port": "RTSP port", "stream": "Stream" + }, + "data_description": { + "host": "The hostname or IP address of your Foscam camera." } } }, From ddba7d8ed8ed3b6d3f482ee0047bb8d8213fedb5 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:05:32 +0100 Subject: [PATCH 334/643] Freebox to FRITZ!Box add host field description (#104784) Co-authored-by: Simone Chemelli --- homeassistant/components/freebox/strings.json | 3 +++ homeassistant/components/fritz/strings.json | 3 +++ homeassistant/components/fritzbox/strings.json | 3 +++ homeassistant/components/fritzbox_callmonitor/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json index 5c4143b4562..eaa56a38da1 100644 --- a/homeassistant/components/freebox/strings.json +++ b/homeassistant/components/freebox/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Freebox router." } }, "link": { diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 7cbb10a236b..5eed2f59fc4 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -26,6 +26,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } } }, diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index d5607aa3090..f4d2fe3670e 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "confirm": { diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 89f049bfbe9..ac36942eec2 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -8,6 +8,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "phonebook": { From 04b72953e6dfd72f02e6096c4bae4dfabae2f109 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 30 Nov 2023 17:11:28 +0100 Subject: [PATCH 335/643] Fix Fastdotcom no entity (#104785) Co-authored-by: G Johansson --- homeassistant/components/fastdotcom/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 33ad4853404..939ab4a40e5 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -24,7 +24,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fast.com sensor.""" - async_add_entities([SpeedtestSensor(hass.data[DOMAIN])]) + async_add_entities([SpeedtestSensor(entry.entry_id, hass.data[DOMAIN])]) # pylint: disable-next=hass-invalid-inheritance # needs fixing @@ -38,9 +38,10 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): _attr_icon = "mdi:speedometer" _attr_should_poll = False - def __init__(self, speedtest_data: dict[str, Any]) -> None: + def __init__(self, entry_id: str, speedtest_data: dict[str, Any]) -> None: """Initialize the sensor.""" self._speedtest_data = speedtest_data + self._attr_unique_id = entry_id async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" From d7de9c13fdd04c7d55c9113d86c6f91e382e53c7 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:04:15 +0100 Subject: [PATCH 336/643] Goalzero to HEOS: add host field description (#104786) --- homeassistant/components/goalzero/strings.json | 3 +++ homeassistant/components/harmony/strings.json | 3 +++ homeassistant/components/heos/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index d94f5219607..c6d85bd4c10 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Goal Zero Yeti." } }, "confirm_discovery": { diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index c9c7a559758..f6862ca3c83 100644 --- a/homeassistant/components/harmony/strings.json +++ b/homeassistant/components/harmony/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "Hub Name" + }, + "data_description": { + "host": "The hostname or IP address of your Logitech Harmony Hub." } }, "link": { diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 7bd362cf3d7..df18fc7834a 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -6,6 +6,9 @@ "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your HEOS device." } } }, From 62537aa63a752161de014d23a1b352d71f7de10d Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:55:53 +0100 Subject: [PATCH 337/643] Frontier to Glances: add host field description (#104787) Co-authored-by: Franck Nijhof --- homeassistant/components/frontier_silicon/strings.json | 5 ++++- homeassistant/components/fully_kiosk/strings.json | 3 +++ homeassistant/components/glances/strings.json | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index a10c3f535a1..03d9f28c016 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -5,10 +5,13 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Frontier Silicon device." } }, "device_config": { - "title": "Device Configuration", + "title": "Device configuration", "description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'", "data": { "pin": "[%key:common::config_flow::data::pin%]" diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index bf46feeec3f..c1a1ef1fcf0 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -13,6 +13,9 @@ "password": "[%key:common::config_flow::data::password%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the device running your Fully Kiosk Browser application." } } }, diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index fdd0c44b31b..1bab098d65f 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -10,6 +10,9 @@ "version": "Glances API Version (2 or 3)", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system running your Glances system monitor." } }, "reauth_confirm": { From fd442fadf8edfbb82ba3b9bb34f4ed4bc744c2b5 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:38:40 +0100 Subject: [PATCH 338/643] HLK to Hyperion: add host field description (#104789) Co-authored-by: Franck Nijhof --- homeassistant/components/hlk_sw16/strings.json | 3 +++ homeassistant/components/hue/strings.json | 6 ++++++ homeassistant/components/hyperion/strings.json | 3 +++ 3 files changed, 12 insertions(+) diff --git a/homeassistant/components/hlk_sw16/strings.json b/homeassistant/components/hlk_sw16/strings.json index d6e3212b4ea..ba74547e355 100644 --- a/homeassistant/components/hlk_sw16/strings.json +++ b/homeassistant/components/hlk_sw16/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hi-Link HLK-SW-16 device." } } }, diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 4022c61bc36..122cb489d26 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -5,12 +5,18 @@ "title": "Pick Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hue bridge." } }, "manual": { "title": "Manual configure a Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hue bridge." } }, "link": { diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index a2f8838e2ea..8d7e3751c4c 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hyperion server." } }, "auth": { From cf63cd33c5eea397b70d2844e000c8461afe6422 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:03:27 +0100 Subject: [PATCH 339/643] iAlarm to Keenetic: add host field description (#104791) Co-authored-by: Andrey Kupreychik --- homeassistant/components/ialarm/strings.json | 3 +++ homeassistant/components/iotawatt/strings.json | 3 +++ homeassistant/components/keenetic_ndms2/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ialarm/strings.json b/homeassistant/components/ialarm/strings.json index 1ac7a25e6f8..cb2c75d74a9 100644 --- a/homeassistant/components/ialarm/strings.json +++ b/homeassistant/components/ialarm/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of Antifurto365 iAlarm system." } } }, diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json index f21dfe0cd09..266b32c5c31 100644 --- a/homeassistant/components/iotawatt/strings.json +++ b/homeassistant/components/iotawatt/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your IoTaWatt device." } }, "auth": { diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 13e3fabfbff..765a3fc4d47 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -9,6 +9,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Keenetic router." } } }, From 75d2ea9c57098e15e23c4ee8718dc0937e0a9a4c Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 30 Nov 2023 16:57:44 +0100 Subject: [PATCH 340/643] KMtronic to LG Soundbar: add host field description (#104792) --- homeassistant/components/kmtronic/strings.json | 3 +++ homeassistant/components/kodi/strings.json | 3 +++ homeassistant/components/lg_soundbar/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json index 2a3a3a40687..6cecea12f22 100644 --- a/homeassistant/components/kmtronic/strings.json +++ b/homeassistant/components/kmtronic/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your KMtronic device." } } }, diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 51431b317d6..7c7d53b33ac 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system hosting your Kodi server." } }, "discovery_confirm": { diff --git a/homeassistant/components/lg_soundbar/strings.json b/homeassistant/components/lg_soundbar/strings.json index 8c6a9909ff5..ee16a39350c 100644 --- a/homeassistant/components/lg_soundbar/strings.json +++ b/homeassistant/components/lg_soundbar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LG Soundbar." } } }, From ea8a47d0e9d542de4a29223e2306d856d547b4d3 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 16:59:26 +0100 Subject: [PATCH 341/643] Fix device sync to Google Assistant if Matter integration is active (#104796) * Only get Matter device info if device is an actual Matter device * Return None if matter device does not exist * lint * fix test * adjust google assistant test --- homeassistant/components/google_assistant/helpers.py | 8 ++++++-- homeassistant/components/matter/helpers.py | 2 +- tests/components/google_assistant/test_helpers.py | 1 + tests/components/matter/test_helpers.py | 11 ++++------- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index af892f15af4..c89925664e0 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -686,8 +686,12 @@ class GoogleEntity: return device # Add Matter info - if "matter" in self.hass.config.components and ( - matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + if ( + "matter" in self.hass.config.components + and any(x for x in device_entry.identifiers if x[0] == "matter") + and ( + matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + ) ): device["matterUniqueId"] = matter_info["unique_id"] device["matterOriginalVendorId"] = matter_info["vendor_id"] diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index dcd6a30ee1f..446d5dc3591 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -94,7 +94,7 @@ def get_node_from_device_entry( ) if device_id_full is None: - raise ValueError(f"Device {device.id} is not a Matter device") + return None device_id = device_id_full.lstrip(device_id_type_prefix) matter_client = matter.matter_client diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 771df137278..aaa3949caaf 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -89,6 +89,7 @@ async def test_google_entity_sync_serialize_with_matter( manufacturer="Someone", model="Some model", sw_version="Some Version", + identifiers={("matter", "12345678")}, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity = entity_registry.async_get_or_create( diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index c7a0ed0d8a3..61988a37122 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -60,16 +60,13 @@ async def test_get_node_from_device_entry( assert node_from_device_entry is node - with pytest.raises(ValueError) as value_error: - await get_node_from_device_entry(hass, other_device_entry) - - assert f"Device {other_device_entry.id} is not a Matter device" in str( - value_error.value - ) + # test non-Matter device returns None + assert get_node_from_device_entry(hass, other_device_entry) is None matter_client.server_info = None + # test non-initialized server raises RuntimeError with pytest.raises(RuntimeError) as runtime_error: - node_from_device_entry = await get_node_from_device_entry(hass, device_entry) + node_from_device_entry = get_node_from_device_entry(hass, device_entry) assert "Matter server information is not available" in str(runtime_error.value) From 0eefc98b3395f55ac610cb40f9654d60e33992da Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:50:58 +0100 Subject: [PATCH 342/643] Fix runtime error in CalDAV (#104800) --- homeassistant/components/caldav/api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index f9236049048..fa89d6acc38 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -11,7 +11,11 @@ async def async_get_calendars( hass: HomeAssistant, client: caldav.DAVClient, component: str ) -> list[caldav.Calendar]: """Get all calendars that support the specified component.""" - calendars = await hass.async_add_executor_job(client.principal().calendars) + + def _get_calendars() -> list[caldav.Calendar]: + return client.principal().calendars() + + calendars = await hass.async_add_executor_job(_get_calendars) components_results = await asyncio.gather( *[ hass.async_add_executor_job(calendar.get_supported_components) From 847fd4c653047dd4b0472cc29a43d73d87595513 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Thu, 30 Nov 2023 08:22:17 -0500 Subject: [PATCH 343/643] Use .get for Fully Kiosk SSL settings in coordinator (#104801) --- homeassistant/components/fully_kiosk/coordinator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py index 17facb79dbb..203251351ae 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -19,13 +19,14 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize.""" + self.use_ssl = entry.data.get(CONF_SSL, False) self.fully = FullyKiosk( async_get_clientsession(hass), entry.data[CONF_HOST], DEFAULT_PORT, entry.data[CONF_PASSWORD], - use_ssl=entry.data[CONF_SSL], - verify_ssl=entry.data[CONF_VERIFY_SSL], + use_ssl=self.use_ssl, + verify_ssl=entry.data.get(CONF_VERIFY_SSL, False), ) super().__init__( hass, @@ -33,7 +34,6 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): name=entry.data[CONF_HOST], update_interval=UPDATE_INTERVAL, ) - self.use_ssl = entry.data[CONF_SSL] async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" From 9d53d6811a28b8eddc7049e03f8b0ca3c1accfb8 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 17:09:53 +0100 Subject: [PATCH 344/643] Bump python-matter-server to version 5.0.0 (#104805) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 174ebb1cab9..f350cda9227 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==4.0.2"] + "requirements": ["python-matter-server==5.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index de84d8fb420..1c5ac428193 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2169,7 +2169,7 @@ python-kasa[speedups]==0.5.4 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2241b1c5d4..f8a4683b88d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1623,7 +1623,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From 83d881459a92cf68ae577739d81cf5c8af109716 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 17:45:27 +0100 Subject: [PATCH 345/643] Add NodeStrClass.__voluptuous_compile__ (#104808) --- homeassistant/util/yaml/objects.py | 7 +++++++ tests/util/yaml/test_init.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index b2320a74d2c..6aedc85cf60 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -2,7 +2,10 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any +import voluptuous as vol +from voluptuous.schema_builder import _compile_scalar import yaml @@ -13,6 +16,10 @@ class NodeListClass(list): class NodeStrClass(str): """Wrapper class to be able to add attributes on a string.""" + def __voluptuous_compile__(self, schema: vol.Schema) -> Any: + """Needed because vol.Schema.compile does not handle str subclasses.""" + return _compile_scalar(self) + class NodeDictClass(dict): """Wrapper class to be able to add attributes on a dict.""" diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 990956ec908..a4b243a4b4a 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -8,6 +8,7 @@ import unittest from unittest.mock import patch import pytest +import voluptuous as vol import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file @@ -615,3 +616,20 @@ def test_string_annotated(try_both_loaders) -> None: getattr(value, "__config_file__", None) == expected_annotations[key][1][0] ) assert getattr(value, "__line__", None) == expected_annotations[key][1][1] + + +def test_string_used_as_vol_schema(try_both_loaders) -> None: + """Test the subclassed strings can be used in voluptuous schemas.""" + conf = "wanted_data:\n key_1: value_1\n key_2: value_2\n" + with io.StringIO(conf) as file: + doc = yaml_loader.parse_yaml(file) + + # Test using the subclassed strings in a schema + schema = vol.Schema( + {vol.Required(key): value for key, value in doc["wanted_data"].items()}, + ) + # Test using the subclassed strings when validating a schema + schema(doc["wanted_data"]) + schema({"key_1": "value_1", "key_2": "value_2"}) + with pytest.raises(vol.Invalid): + schema({"key_1": "value_2", "key_2": "value_1"}) From 7a36bdb052faf3873370698b163ba31e735a174a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Nov 2023 17:26:07 +0100 Subject: [PATCH 346/643] Make Shelly Wall Display thermostat implementation compatible with firmware 1.2.5 (#104812) --- homeassistant/components/shelly/climate.py | 11 ++++------- homeassistant/components/shelly/switch.py | 5 +++-- homeassistant/components/shelly/utils.py | 7 ------- tests/components/shelly/conftest.py | 3 +-- tests/components/shelly/test_switch.py | 11 +++++++---- 5 files changed, 15 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index d855e8b238b..6a592c904f6 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -42,12 +42,7 @@ from .const import ( ) from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyRpcEntity -from .utils import ( - async_remove_shelly_entity, - get_device_entry_gen, - get_rpc_key_ids, - is_relay_used_as_actuator, -) +from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( @@ -131,7 +126,9 @@ def async_setup_rpc_entry( for id_ in climate_key_ids: climate_ids.append(id_) - if is_relay_used_as_actuator(id_, coordinator.mac, coordinator.device.config): + if coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is used as the thermostat actuator, + # we need to remove a switch entity unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "switch", unique_id) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 35429c858f5..5a398182e4d 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -118,8 +118,9 @@ def async_setup_rpc_entry( continue if coordinator.model == MODEL_WALL_DISPLAY: - if coordinator.device.shelly["relay_operational"]: - # Wall Display in relay mode, we need to remove a climate entity + if not coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is not used as the thermostat actuator, + # we need to remove a climate entity unique_id = f"{coordinator.mac}-thermostat:{id_}" async_remove_shelly_entity(hass, "climate", unique_id) else: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 0209dc63aa8..6b5c59f28db 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -430,10 +430,3 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: return None return GEN1_RELEASE_URL if gen == 1 else GEN2_RELEASE_URL - - -def is_relay_used_as_actuator(relay_id: int, mac: str, config: dict[str, Any]) -> bool: - """Return True if an internal relay is used as the thermostat actuator.""" - return f"{mac}/c/switch:{relay_id}".lower() in config[f"thermostat:{relay_id}"].get( - "actuator", "" - ) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 1662405dc80..6eb74e26dcb 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -153,7 +153,6 @@ MOCK_CONFIG = { "id": 0, "enable": True, "type": "heating", - "actuator": f"shelly://shellywalldisplay-{MOCK_MAC.lower()}/c/switch:0", }, "sys": { "ui_data": {}, @@ -181,7 +180,7 @@ MOCK_SHELLY_RPC = { "auth_en": False, "auth_domain": None, "profile": "cover", - "relay_operational": False, + "relay_in_thermostat": True, } MOCK_STATUS_COAP = { diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 69e1423f75a..e19416706e1 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -283,7 +283,8 @@ async def test_block_device_gas_valve( async def test_wall_display_thermostat_mode( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, + mock_rpc_device, ) -> None: """Test Wall Display in thermostat mode.""" await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) @@ -294,7 +295,10 @@ async def test_wall_display_thermostat_mode( async def test_wall_display_relay_mode( - hass: HomeAssistant, entity_registry, mock_rpc_device, monkeypatch + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_rpc_device, + monkeypatch, ) -> None: """Test Wall Display in thermostat mode.""" entity_id = register_entity( @@ -305,8 +309,7 @@ async def test_wall_display_relay_mode( ) new_shelly = deepcopy(mock_rpc_device.shelly) - new_shelly["relay_operational"] = True - + new_shelly["relay_in_thermostat"] = False monkeypatch.setattr(mock_rpc_device, "shelly", new_shelly) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) From 7e012183da107c251527e89762230000ef227413 Mon Sep 17 00:00:00 2001 From: Mappenhei <124632181+Mappenhei@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:45:52 +0100 Subject: [PATCH 347/643] Add Humidity device class to LaCross humidity sensor (#104814) --- homeassistant/components/lacrosse/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index 7355a60f5f0..40d38da55eb 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -209,7 +209,7 @@ class LaCrosseHumidity(LaCrosseSensor): _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT - _attr_icon = "mdi:water-percent" + _attr_device_class = SensorDeviceClass.HUMIDITY @property def native_value(self) -> int | None: From 43e0ddc74ee964507437d7d84805b0152dc8ab4e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Nov 2023 18:46:18 +0100 Subject: [PATCH 348/643] Address late review for the host field description in Shelly integration (#104815) Co-authored-by: Franck Nijhof --- homeassistant/components/shelly/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 49c66a56459..9230ae605e0 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -8,7 +8,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of the Shelly device to control." + "host": "The hostname or IP address of the Shelly device to connect to." } }, "credentials": { From 7739f9923379dccfaf4338421a1a27a94352cd58 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Nov 2023 17:49:31 +0100 Subject: [PATCH 349/643] Update frontend to 20231130.0 (#104816) --- 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 7a587d56d74..b6668383b54 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==20231129.1"] + "requirements": ["home-assistant-frontend==20231130.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 75209de5996..7dad258068d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1c5ac428193..54a598ad9b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8a4683b88d..82a65044067 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231129.1 +home-assistant-frontend==20231130.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From 45f79ee1ba25fb4c0434bb8f0b61854086e8419b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 18:14:48 +0100 Subject: [PATCH 350/643] Restore renamed yaml loader classes and warn when used (#104818) --- homeassistant/util/yaml/loader.py | 25 ++++++++++++++ tests/util/yaml/test_init.py | 55 ++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index fbffae448b2..e8f4a734bdb 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -23,6 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -136,6 +137,18 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets +class SafeLoader(FastSafeLoader): + """Provided for backwards compatibility. Logs when instantiated.""" + + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + report( + "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + FastSafeLoader.__init__(*args, **kwargs) + + class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -145,6 +158,18 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets +class SafeLineLoader(PythonSafeLoader): + """Provided for backwards compatibility. Logs when instantiated.""" + + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + report( + "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + PythonSafeLoader.__init__(*args, **kwargs) + + LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index a4b243a4b4a..c4e5c58e235 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -1,11 +1,12 @@ """Test Home Assistant yaml loader.""" +from collections.abc import Generator import importlib import io import os import pathlib from typing import Any import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest import voluptuous as vol @@ -585,6 +586,58 @@ async def test_loading_actual_file_with_syntax_error( await hass.async_add_executor_job(load_yaml_config_file, fixture_path) +@pytest.fixture +def mock_integration_frame() -> Generator[Mock, None, None]: + """Mock as if we're calling code from inside an integration.""" + correct_frame = Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ) + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + yield correct_frame + + +@pytest.mark.parametrize( + ("loader_class", "message"), + [ + (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), + ( + yaml.loader.SafeLineLoader, + "'SafeLineLoader' instead of 'PythonSafeLoader'", + ), + ], +) +async def test_deprecated_loaders( + hass: HomeAssistant, + mock_integration_frame: Mock, + caplog: pytest.LogCaptureFixture, + loader_class, + message: str, +) -> None: + """Test instantiating the deprecated yaml loaders logs a warning.""" + with pytest.raises(TypeError), patch( + "homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set() + ): + loader_class() + assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text + + def test_string_annotated(try_both_loaders) -> None: """Test strings are annotated with file + line.""" conf = ( From 208622e8a7cd0d65e1b0537afb5ee787d3744e1e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Nov 2023 18:45:04 +0100 Subject: [PATCH 351/643] Revert "Add Komfovent (#95722)" (#104819) --- .coveragerc | 2 - CODEOWNERS | 2 - .../components/komfovent/__init__.py | 34 ---- homeassistant/components/komfovent/climate.py | 91 --------- .../components/komfovent/config_flow.py | 74 ------- homeassistant/components/komfovent/const.py | 3 - .../components/komfovent/manifest.json | 9 - .../components/komfovent/strings.json | 22 -- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/komfovent/__init__.py | 1 - tests/components/komfovent/conftest.py | 14 -- .../components/komfovent/test_config_flow.py | 189 ------------------ 15 files changed, 454 deletions(-) delete mode 100644 homeassistant/components/komfovent/__init__.py delete mode 100644 homeassistant/components/komfovent/climate.py delete mode 100644 homeassistant/components/komfovent/config_flow.py delete mode 100644 homeassistant/components/komfovent/const.py delete mode 100644 homeassistant/components/komfovent/manifest.json delete mode 100644 homeassistant/components/komfovent/strings.json delete mode 100644 tests/components/komfovent/__init__.py delete mode 100644 tests/components/komfovent/conftest.py delete mode 100644 tests/components/komfovent/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index f15d36918ec..27aed1e1009 100644 --- a/.coveragerc +++ b/.coveragerc @@ -633,8 +633,6 @@ omit = homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/media_player.py homeassistant/components/kodi/notify.py - homeassistant/components/komfovent/__init__.py - homeassistant/components/komfovent/climate.py homeassistant/components/konnected/__init__.py homeassistant/components/konnected/panel.py homeassistant/components/konnected/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index ec32f941d56..5ecb7d75cc8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -663,8 +663,6 @@ build.json @home-assistant/supervisor /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund /tests/components/kodi/ @OnFreund -/homeassistant/components/komfovent/ @ProstoSanja -/tests/components/komfovent/ @ProstoSanja /homeassistant/components/konnected/ @heythisisnate /tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm diff --git a/homeassistant/components/komfovent/__init__.py b/homeassistant/components/komfovent/__init__.py deleted file mode 100644 index 0366a429b21..00000000000 --- a/homeassistant/components/komfovent/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -"""The Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import DOMAIN - -PLATFORMS: list[Platform] = [Platform.CLIMATE] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Komfovent from a config entry.""" - host = entry.data[CONF_HOST] - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - _, credentials = komfovent_api.get_credentials(host, username, password) - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - raise ConfigEntryNotReady(f"Unable to connect to {host}: {result}") - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (credentials, settings) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/komfovent/climate.py b/homeassistant/components/komfovent/climate.py deleted file mode 100644 index 2e51fddf4f2..00000000000 --- a/homeassistant/components/komfovent/climate.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Ventilation Units from Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.components.climate import ( - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN - -HASS_TO_KOMFOVENT_MODES = { - HVACMode.COOL: komfovent_api.KomfoventModes.COOL, - HVACMode.HEAT_COOL: komfovent_api.KomfoventModes.HEAT_COOL, - HVACMode.OFF: komfovent_api.KomfoventModes.OFF, - HVACMode.AUTO: komfovent_api.KomfoventModes.AUTO, -} -KOMFOVENT_TO_HASS_MODES = {v: k for k, v in HASS_TO_KOMFOVENT_MODES.items()} - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Komfovent unit control.""" - credentials, settings = hass.data[DOMAIN][entry.entry_id] - async_add_entities([KomfoventDevice(credentials, settings)], True) - - -class KomfoventDevice(ClimateEntity): - """Representation of a ventilation unit.""" - - _attr_hvac_modes = list(HASS_TO_KOMFOVENT_MODES.keys()) - _attr_preset_modes = [mode.name for mode in komfovent_api.KomfoventPresets] - _attr_supported_features = ClimateEntityFeature.PRESET_MODE - _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True - _attr_name = None - - def __init__( - self, - credentials: komfovent_api.KomfoventCredentials, - settings: komfovent_api.KomfoventSettings, - ) -> None: - """Initialize the ventilation unit.""" - self._komfovent_credentials = credentials - self._komfovent_settings = settings - - self._attr_unique_id = settings.serial_number - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, settings.serial_number)}, - model=settings.model, - name=settings.name, - serial_number=settings.serial_number, - sw_version=settings.version, - manufacturer="Komfovent", - ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new target preset mode.""" - await komfovent_api.set_preset( - self._komfovent_credentials, - komfovent_api.KomfoventPresets[preset_mode], - ) - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - await komfovent_api.set_mode( - self._komfovent_credentials, HASS_TO_KOMFOVENT_MODES[hvac_mode] - ) - - async def async_update(self) -> None: - """Get the latest data.""" - result, status = await komfovent_api.get_unit_status( - self._komfovent_credentials - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS or not status: - self._attr_available = False - return - self._attr_available = True - self._attr_preset_mode = status.preset - self._attr_current_temperature = status.temp_extract - self._attr_hvac_mode = KOMFOVENT_TO_HASS_MODES[status.mode] diff --git a/homeassistant/components/komfovent/config_flow.py b/homeassistant/components/komfovent/config_flow.py deleted file mode 100644 index fb5390a30c6..00000000000 --- a/homeassistant/components/komfovent/config_flow.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Config flow for Komfovent integration.""" -from __future__ import annotations - -import logging -from typing import Any - -import komfovent_api -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER = "user" -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Optional(CONF_USERNAME, default="user"): str, - vol.Required(CONF_PASSWORD): str, - } -) - -ERRORS_MAP = { - komfovent_api.KomfoventConnectionResult.NOT_FOUND: "cannot_connect", - komfovent_api.KomfoventConnectionResult.UNAUTHORISED: "invalid_auth", - komfovent_api.KomfoventConnectionResult.INVALID_INPUT: "invalid_input", -} - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Komfovent.""" - - VERSION = 1 - - def __return_error( - self, result: komfovent_api.KomfoventConnectionResult - ) -> FlowResult: - return self.async_show_form( - step_id=STEP_USER, - data_schema=STEP_USER_DATA_SCHEMA, - errors={"base": ERRORS_MAP.get(result, "unknown")}, - ) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id=STEP_USER, data_schema=STEP_USER_DATA_SCHEMA - ) - - conf_host = user_input[CONF_HOST] - conf_username = user_input[CONF_USERNAME] - conf_password = user_input[CONF_PASSWORD] - - result, credentials = komfovent_api.get_credentials( - conf_host, conf_username, conf_password - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - await self.async_set_unique_id(settings.serial_number) - self._abort_if_unique_id_configured() - - return self.async_create_entry(title=settings.name, data=user_input) diff --git a/homeassistant/components/komfovent/const.py b/homeassistant/components/komfovent/const.py deleted file mode 100644 index a7881a58c41..00000000000 --- a/homeassistant/components/komfovent/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Komfovent integration.""" - -DOMAIN = "komfovent" diff --git a/homeassistant/components/komfovent/manifest.json b/homeassistant/components/komfovent/manifest.json deleted file mode 100644 index cbe00ef8dc5..00000000000 --- a/homeassistant/components/komfovent/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "komfovent", - "name": "Komfovent", - "codeowners": ["@ProstoSanja"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/komfovent", - "iot_class": "local_polling", - "requirements": ["komfovent-api==0.0.3"] -} diff --git a/homeassistant/components/komfovent/strings.json b/homeassistant/components/komfovent/strings.json deleted file mode 100644 index 074754c1fe0..00000000000 --- a/homeassistant/components/komfovent/strings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "host": "[%key:common::config_flow::data::host%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_input": "Failed to parse provided hostname", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - } - } -} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 57503f0ef32..eeee6532792 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -246,7 +246,6 @@ FLOWS = { "kmtronic", "knx", "kodi", - "komfovent", "konnected", "kostal_plenticore", "kraken", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f0af72624f6..2c6d8277309 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2881,12 +2881,6 @@ "config_flow": true, "iot_class": "local_push" }, - "komfovent": { - "name": "Komfovent", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, "konnected": { "name": "Konnected.io", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 54a598ad9b1..8e9ad9fb404 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1128,9 +1128,6 @@ kiwiki-client==0.1.1 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82a65044067..bc70a0d4124 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -885,9 +885,6 @@ kegtron-ble==0.4.0 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/komfovent/__init__.py b/tests/components/komfovent/__init__.py deleted file mode 100644 index e5492a52327..00000000000 --- a/tests/components/komfovent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Komfovent integration.""" diff --git a/tests/components/komfovent/conftest.py b/tests/components/komfovent/conftest.py deleted file mode 100644 index d9cb0950c74..00000000000 --- a/tests/components/komfovent/conftest.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Common fixtures for the Komfovent tests.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch - -import pytest - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.komfovent.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry diff --git a/tests/components/komfovent/test_config_flow.py b/tests/components/komfovent/test_config_flow.py deleted file mode 100644 index 008d92e36a3..00000000000 --- a/tests/components/komfovent/test_config_flow.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Test the Komfovent config flow.""" -from unittest.mock import AsyncMock, patch - -import komfovent_api -import pytest - -from homeassistant import config_entries -from homeassistant.components.komfovent.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType - -from tests.common import MockConfigEntry - - -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test flow completes as expected.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_authenticating( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow authentication step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_device_info( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow device info download step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_device_already_exists( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test device is not added when it already exists.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - unique_id="test-uid", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.ABORT - assert final_result["reason"] == "already_configured" - - -async def __test_normal_flow(hass: HomeAssistant, flow_id: str) -> FlowResult: - """Test flow completing as expected, no matter what happened before.""" - - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventSettings("test-name", None, None, "test-uid"), - ), - ): - final_result = await hass.config_entries.flow.async_configure( - flow_id, - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - return final_result From 93c8618f8ad04490bf2c7cc4e736699b88d9a32c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Nov 2023 19:48:24 +0100 Subject: [PATCH 352/643] Bump version to 2023.12.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1fbd97159a7..9e1fda9865a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index bd1168d11de..c5a1e2705d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b0" +version = "2023.12.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From cd1ee70707a792e461e972d3d33dfd414113c74a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Nov 2023 19:58:33 +0100 Subject: [PATCH 353/643] Use orjson to load HomeWizard test fixtures (#104832) --- tests/components/homewizard/conftest.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index e778c82928b..0c24d9daebe 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -1,6 +1,5 @@ """Fixtures for HomeWizard integration tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch from homewizard_energy.errors import NotFoundError @@ -11,7 +10,7 @@ from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, get_fixture_path, load_fixture +from tests.common import MockConfigEntry, get_fixture_path, load_json_object_fixture @pytest.fixture @@ -35,22 +34,22 @@ def mock_homewizardenergy( client = homewizard.return_value client.device.return_value = Device.from_dict( - json.loads(load_fixture(f"{device_fixture}/device.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) ) client.data.return_value = Data.from_dict( - json.loads(load_fixture(f"{device_fixture}/data.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) ) if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists(): client.state.return_value = State.from_dict( - json.loads(load_fixture(f"{device_fixture}/state.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) ) else: client.state.side_effect = NotFoundError if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists(): client.system.return_value = System.from_dict( - json.loads(load_fixture(f"{device_fixture}/system.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) ) else: client.system.side_effect = NotFoundError From 4829b21fd0d78477711153b335ae915f20ed0daa Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 20:08:58 +0100 Subject: [PATCH 354/643] Update Matter test fixtures to schema version 5 (#104829) --- .../fixtures/config_entry_diagnostics.json | 68 ++++----- .../config_entry_diagnostics_redacted.json | 68 ++++----- .../nodes/color-temperature-light.json | 114 +++++++------- .../fixtures/nodes/device_diagnostics.json | 76 +++++----- .../matter/fixtures/nodes/dimmable-light.json | 80 +++++----- .../fixtures/nodes/door-lock-with-unbolt.json | 142 +++++++++--------- .../matter/fixtures/nodes/door-lock.json | 142 +++++++++--------- .../fixtures/nodes/eve-contact-sensor.json | 120 +++++++-------- .../fixtures/nodes/extended-color-light.json | 114 +++++++------- .../matter/fixtures/nodes/flow-sensor.json | 12 +- .../fixtures/nodes/generic-switch-multi.json | 25 ++- .../matter/fixtures/nodes/generic-switch.json | 12 +- .../fixtures/nodes/humidity-sensor.json | 12 +- .../matter/fixtures/nodes/light-sensor.json | 12 +- .../fixtures/nodes/occupancy-sensor.json | 12 +- .../fixtures/nodes/on-off-plugin-unit.json | 12 +- .../fixtures/nodes/onoff-light-alt-name.json | 72 ++++----- .../fixtures/nodes/onoff-light-no-name.json | 72 ++++----- .../matter/fixtures/nodes/onoff-light.json | 80 +++++----- .../fixtures/nodes/pressure-sensor.json | 12 +- .../matter/fixtures/nodes/switch-unit.json | 12 +- .../fixtures/nodes/temperature-sensor.json | 12 +- .../matter/fixtures/nodes/thermostat.json | 124 +++++++-------- .../fixtures/nodes/window-covering_full.json | 120 +++++++-------- .../fixtures/nodes/window-covering_lift.json | 120 +++++++-------- .../nodes/window-covering_pa-lift.json | 99 ++++++------ .../nodes/window-covering_pa-tilt.json | 120 +++++++-------- .../fixtures/nodes/window-covering_tilt.json | 120 +++++++-------- 28 files changed, 990 insertions(+), 994 deletions(-) diff --git a/tests/components/matter/fixtures/config_entry_diagnostics.json b/tests/components/matter/fixtures/config_entry_diagnostics.json index 53477792e43..f591709fbda 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics.json @@ -40,11 +40,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -76,8 +76,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -122,8 +122,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -155,14 +155,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -503,19 +503,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -540,20 +540,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 8a67ef0fb63..c85ee4d70e3 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -42,11 +42,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -78,8 +78,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -124,8 +124,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -157,14 +157,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -317,19 +317,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -354,20 +354,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/color-temperature-light.json b/tests/components/matter/fixtures/nodes/color-temperature-light.json index 7552fa833fb..45d1c18635c 100644 --- a/tests/components/matter/fixtures/nodes/color-temperature-light.json +++ b/tests/components/matter/fixtures/nodes/color-temperature-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-color-temperature-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 268, - "revision": 1 + "0": 268, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 3abecbdf66f..d95fbe5efa9 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -13,8 +13,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -30,11 +30,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -66,8 +66,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -112,8 +112,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -143,14 +143,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -302,19 +302,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -339,20 +339,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -415,8 +415,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index e14c922857c..7ccc3eef3af 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-dimmable-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json index 6cbd75ab09c..dfa7794f28b 100644 --- a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json +++ b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/door-lock.json b/tests/components/matter/fixtures/nodes/door-lock.json index 1477d78aa67..8a3f0fd68dd 100644 --- a/tests/components/matter/fixtures/nodes/door-lock.json +++ b/tests/components/matter/fixtures/nodes/door-lock.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json index b0eacfb621c..a009796f940 100644 --- a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json +++ b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json @@ -12,16 +12,16 @@ "0/53/47": 0, "0/53/8": [ { - "extAddress": 12872547289273451492, - "rloc16": 1024, - "routerId": 1, - "nextHop": 0, - "pathCost": 0, - "LQIIn": 3, - "LQIOut": 3, - "age": 142, - "allocated": true, - "linkEstablished": true + "0": 12872547289273451492, + "1": 1024, + "2": 1, + "3": 0, + "4": 0, + "5": 3, + "6": 3, + "7": 142, + "8": true, + "9": true } ], "0/53/29": 1556, @@ -30,20 +30,20 @@ "0/53/40": 519, "0/53/7": [ { - "extAddress": 12872547289273451492, - "age": 654, - "rloc16": 1024, - "linkFrameCounter": 738, - "mleFrameCounter": 418, - "lqi": 3, - "averageRssi": -50, - "lastRssi": -51, - "frameErrorRate": 5, - "messageErrorRate": 0, - "rxOnWhenIdle": true, - "fullThreadDevice": true, - "fullNetworkData": true, - "isChild": false + "0": 12872547289273451492, + "1": 654, + "2": 1024, + "3": 738, + "4": 418, + "5": 3, + "6": -50, + "7": -51, + "8": 5, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false } ], "0/53/33": 66, @@ -124,9 +124,9 @@ "0/53/16": 0, "0/42/0": [ { - "providerNodeID": 1773685588, - "endpoint": 0, - "fabricIndex": 1 + "1": 1773685588, + "2": 0, + "254": 1 } ], "0/42/65528": [], @@ -140,8 +140,8 @@ "0/48/65532": 0, "0/48/65528": [1, 3, 5], "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], @@ -158,25 +158,25 @@ "0/31/1": [], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/65532": 0, @@ -187,8 +187,8 @@ "0/49/65533": 1, "0/49/1": [ { - "networkID": "Uv50lWMtT7s=", - "connected": true + "0": "Uv50lWMtT7s=", + "1": true } ], "0/49/3": 20, @@ -217,8 +217,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 46, 48, 49, 51, 53, 60, 62, 63], @@ -226,18 +226,18 @@ "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "ieee802154", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YtmXHFJ/dhk=", - "IPv4Addresses": [], - "IPv6Addresses": [ + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "YtmXHFJ/dhk=", + "5": [], + "6": [ "/RG+U41GAABynlpPU50e5g==", "/oAAAAAAAABg2ZccUn92GQ==", "/VL+dJVjAAB1cwmi02rvTA==" ], - "type": 4 + "7": 4 } ], "0/51/65529": [0], @@ -261,8 +261,8 @@ "0/40/6": "**REDACTED**", "0/40/3": "Eve Door", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/2": 4874, "0/40/65532": 0, @@ -302,8 +302,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 21, - "revision": 1 + "0": 21, + "1": 1 } ], "1/29/65528": [], diff --git a/tests/components/matter/fixtures/nodes/extended-color-light.json b/tests/components/matter/fixtures/nodes/extended-color-light.json index f4d83239b6d..d18b76768ca 100644 --- a/tests/components/matter/fixtures/nodes/extended-color-light.json +++ b/tests/components/matter/fixtures/nodes/extended-color-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-extended-color-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 269, - "revision": 1 + "0": 269, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/flow-sensor.json b/tests/components/matter/fixtures/nodes/flow-sensor.json index e1fc2a36585..a8dad202fa1 100644 --- a/tests/components/matter/fixtures/nodes/flow-sensor.json +++ b/tests/components/matter/fixtures/nodes/flow-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-flow-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 774, - "revision": 1 + "0": 774, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/generic-switch-multi.json b/tests/components/matter/fixtures/nodes/generic-switch-multi.json index 15c93825307..f564e91a1ce 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch-multi.json +++ b/tests/components/matter/fixtures/nodes/generic-switch-multi.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], @@ -77,17 +77,16 @@ "1/59/65528": [], "1/64/0": [ { - "label": "Label", - "value": "1" + "0": "Label", + "1": "1" } ], - "2/3/65529": [0, 64], "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "2/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "2/29/1": [3, 29, 59], @@ -107,8 +106,8 @@ "2/59/65528": [], "2/64/0": [ { - "label": "Label", - "value": "Fancy Button" + "0": "Label", + "1": "Fancy Button" } ] }, diff --git a/tests/components/matter/fixtures/nodes/generic-switch.json b/tests/components/matter/fixtures/nodes/generic-switch.json index 30763c88e5b..80773915748 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch.json +++ b/tests/components/matter/fixtures/nodes/generic-switch.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], diff --git a/tests/components/matter/fixtures/nodes/humidity-sensor.json b/tests/components/matter/fixtures/nodes/humidity-sensor.json index a1940fc1857..8220c9cf8f8 100644 --- a/tests/components/matter/fixtures/nodes/humidity-sensor.json +++ b/tests/components/matter/fixtures/nodes/humidity-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-humidity-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 775, - "revision": 1 + "0": 775, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/light-sensor.json b/tests/components/matter/fixtures/nodes/light-sensor.json index 93583c34292..c4d84bc7923 100644 --- a/tests/components/matter/fixtures/nodes/light-sensor.json +++ b/tests/components/matter/fixtures/nodes/light-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-light-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 262, - "revision": 1 + "0": 262, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/occupancy-sensor.json b/tests/components/matter/fixtures/nodes/occupancy-sensor.json index d8f2580c2b0..f63dd43362b 100644 --- a/tests/components/matter/fixtures/nodes/occupancy-sensor.json +++ b/tests/components/matter/fixtures/nodes/occupancy-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 263, - "revision": 1 + "0": 263, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json index 43ba486bc29..8d523f5443a 100644 --- a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json +++ b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-plugin-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -118,8 +118,8 @@ ], "1/29/0": [ { - "deviceType": 266, - "revision": 1 + "0": 266, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json index f29361da128..3f6e83ca460 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json index 8a1134409a9..18cb68c8926 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light.json b/tests/components/matter/fixtures/nodes/onoff-light.json index 65ef0be5c8e..eed404ff85d 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light.json +++ b/tests/components/matter/fixtures/nodes/onoff-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/pressure-sensor.json b/tests/components/matter/fixtures/nodes/pressure-sensor.json index a47cda28056..d38ac560ac5 100644 --- a/tests/components/matter/fixtures/nodes/pressure-sensor.json +++ b/tests/components/matter/fixtures/nodes/pressure-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-pressure-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 773, - "revision": 1 + "0": 773, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/switch-unit.json b/tests/components/matter/fixtures/nodes/switch-unit.json index ceed22d2524..e16f1e406ec 100644 --- a/tests/components/matter/fixtures/nodes/switch-unit.json +++ b/tests/components/matter/fixtures/nodes/switch-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 99999, - "revision": 1 + "0": 99999, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-switch-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -96,8 +96,8 @@ "1/7/65531": [0, 16, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 9999999, - "revision": 1 + "0": 9999999, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/temperature-sensor.json b/tests/components/matter/fixtures/nodes/temperature-sensor.json index c7d372ac2d7..0abb366f81b 100644 --- a/tests/components/matter/fixtures/nodes/temperature-sensor.json +++ b/tests/components/matter/fixtures/nodes/temperature-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 770, - "revision": 1 + "0": 770, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/thermostat.json b/tests/components/matter/fixtures/nodes/thermostat.json index 85ac42e5429..a7abff41331 100644 --- a/tests/components/matter/fixtures/nodes/thermostat.json +++ b/tests/components/matter/fixtures/nodes/thermostat.json @@ -8,8 +8,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 48, 49, 50, 51, 54, 60, 62, 63, 64], @@ -22,18 +22,18 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -64,8 +64,8 @@ "0/40/17": true, "0/40/18": "3D06D025F9E026A0", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -86,8 +86,8 @@ "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -100,8 +100,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -122,18 +122,18 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "3FR1X7qs", - "IPv4Addresses": ["wKgI7g=="], - "IPv6Addresses": [ + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "3FR1X7qs", + "5": ["wKgI7g=="], + "6": [ "/oAAAAAAAADeVHX//l+6rA==", "JA4DsgZ9jUDeVHX//l+6rA==", "/UgvJAe/AADeVHX//l+6rA==" ], - "type": 1 + "7": 1 } ], "0/51/1": 4, @@ -182,32 +182,32 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", - "vendorID": 4996, - "fabricID": 1, - "nodeID": 1425709672, - "label": "", - "fabricIndex": 1 + "1": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", + "2": 4996, + "3": 1, + "4": 1425709672, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", - "vendorID": 65521, - "fabricID": 1, - "nodeID": 4, - "label": "", - "fabricIndex": 2 + "1": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", + "2": 65521, + "3": 1, + "4": 4, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -233,20 +233,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -275,8 +275,8 @@ "1/6/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 769, - "revision": 1 + "0": 769, + "1": 1 } ], "1/29/1": [3, 4, 6, 29, 30, 64, 513, 514, 516], @@ -295,20 +295,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_full.json b/tests/components/matter/fixtures/nodes/window-covering_full.json index feb75409526..fc6efe2077c 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_full.json +++ b/tests/components/matter/fixtures/nodes/window-covering_full.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-full-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_lift.json b/tests/components/matter/fixtures/nodes/window-covering_lift.json index afc2a2f734f..9c58869e988 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_lift.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-lift-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json index 8d3335bbd6c..fe970b6ed6b 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "7630EF9998EDF03C", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -117,8 +117,8 @@ "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -131,8 +131,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -153,17 +153,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "hPcDB5/k", - "IPv4Addresses": ["wKgIhg=="], - "IPv6Addresses": [ - "/oAAAAAAAACG9wP//gef5A==", - "JA4DsgZ+bsCG9wP//gef5A==" - ], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "hPcDB5/k", + "5": ["wKgIhg=="], + "6": ["/oAAAAAAAACG9wP//gef5A==", "JA4DsgZ+bsCG9wP//gef5A=="], + "7": 1 } ], "0/51/1": 35, @@ -201,19 +198,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 2 + "1": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -239,20 +236,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -281,8 +278,8 @@ "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 514, - "revision": 1 + "0": 514, + "1": 1 } ], "1/29/1": [3, 4, 29, 30, 64, 65, 258], @@ -301,20 +298,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json index 44347dbd964..92a1d820d2e 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock_pa_tilt_window_covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_tilt.json b/tests/components/matter/fixtures/nodes/window-covering_tilt.json index a33e0f24c3f..144348b5c76 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-tilt-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], From ea732349c9bc93d09b23dcd966f4dce93ed5b91f Mon Sep 17 00:00:00 2001 From: cdnninja Date: Thu, 30 Nov 2023 14:19:02 -0700 Subject: [PATCH 355/643] Vesync constant cleanup (#104842) --- homeassistant/components/vesync/const.py | 12 ++++++++++++ homeassistant/components/vesync/fan.py | 12 +----------- homeassistant/components/vesync/light.py | 9 +-------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index a0e5b9da52e..b2fd090e781 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -18,9 +18,21 @@ DEV_TYPE_TO_HA = { "ESWL01": "switch", "ESWL03": "switch", "ESO15-TB": "outlet", + "LV-PUR131S": "fan", + "Core200S": "fan", + "Core300S": "fan", + "Core400S": "fan", + "Core600S": "fan", + "Vital200S": "fan", + "Vital100S": "fan", + "ESD16": "walldimmer", + "ESWD16": "walldimmer", + "ESL100": "bulb-dimmable", + "ESL100CW": "bulb-tunable-white", } SKU_TO_BASE_DEVICE = { + # Air Purifiers "LV-PUR131S": "LV-PUR131S", "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S "Core200S": "Core200S", diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 326e7daf12c..22983054dc9 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -17,20 +17,10 @@ from homeassistant.util.percentage import ( ) from .common import VeSyncDevice -from .const import DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS +from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "LV-PUR131S": "fan", - "Core200S": "fan", - "Core300S": "fan", - "Core400S": "fan", - "Core600S": "fan", - "Vital200S": "fan", - "Vital100S": "fan", -} - FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" FAN_MODE_PET = "pet" diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index e6cc979e808..040e9d5696d 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -14,17 +14,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import VeSyncDevice -from .const import DOMAIN, VS_DISCOVERY, VS_LIGHTS +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_LIGHTS _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "ESD16": "walldimmer", - "ESWD16": "walldimmer", - "ESL100": "bulb-dimmable", - "ESL100CW": "bulb-tunable-white", -} - async def async_setup_entry( hass: HomeAssistant, From b0f5b78b9a6ea1191ff85354d5c57c50976a684c Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 30 Nov 2023 16:21:34 -0500 Subject: [PATCH 356/643] Fix Harmony switch removal version (#104838) --- homeassistant/components/harmony/switch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index a3c588c06bb..6b833df9720 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -27,7 +27,7 @@ async def async_setup_entry( hass, DOMAIN, "deprecated_switches", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2024.6.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_switches", @@ -91,7 +91,7 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): self.hass, DOMAIN, f"deprecated_switches_{self.entity_id}_{item}", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2024.6.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_switches_entity", From 3e95c1c705c5edb1ae7f8cabd3a6ae6c2aa7fb30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 30 Nov 2023 22:39:54 +0100 Subject: [PATCH 357/643] Bump Mill library (#104836) --- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index cb0ba4522bf..7bb78eb05e7 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.6", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.7", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 11cad3a4536..2d38ab1ed4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1249,7 +1249,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index badde74823a..6d154d7b45f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -973,7 +973,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 From 99f28c7163aa396b6fa02b9f63781e17a05cff7b Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 30 Nov 2023 16:40:41 -0500 Subject: [PATCH 358/643] Late review updates for Blink (#104755) --- homeassistant/components/blink/__init__.py | 4 +- homeassistant/components/blink/services.py | 79 ++++++++----- homeassistant/components/blink/strings.json | 17 +++ tests/components/blink/test_services.py | 118 +++++++++----------- 4 files changed, 122 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 42ad5cabeb7..d83c2686563 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -25,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS from .coordinator import BlinkUpdateCoordinator -from .services import async_setup_services +from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -74,7 +74,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Blink.""" - await async_setup_services(hass) + setup_services(hass) return True diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 8ea0b6c03a4..12ac0d3b859 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -1,8 +1,6 @@ """Services for the Blink integration.""" from __future__ import annotations -import logging - import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -14,7 +12,7 @@ from homeassistant.const import ( CONF_PIN, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr @@ -27,56 +25,67 @@ from .const import ( ) from .coordinator import BlinkUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILENAME): cv.string, } ) SERVICE_SEND_PIN_SCHEMA = vol.Schema( - {vol.Required(ATTR_DEVICE_ID): cv.ensure_list, vol.Optional(CONF_PIN): cv.string} + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_PIN): cv.string, + } ) SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILE_PATH): cv.string, } ) -async def async_setup_services(hass: HomeAssistant) -> None: +def setup_services(hass: HomeAssistant) -> None: """Set up the services for the Blink integration.""" - async def collect_coordinators( + def collect_coordinators( device_ids: list[str], ) -> list[BlinkUpdateCoordinator]: - config_entries = list[ConfigEntry]() + config_entries: list[ConfigEntry] = [] registry = dr.async_get(hass) for target in device_ids: device = registry.async_get(target) if device: - device_entries = list[ConfigEntry]() + device_entries: list[ConfigEntry] = [] for entry_id in device.config_entries: entry = hass.config_entries.async_get_entry(entry_id) if entry and entry.domain == DOMAIN: device_entries.append(entry) if not device_entries: - raise HomeAssistantError( - f"Device '{target}' is not a {DOMAIN} device" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device", + translation_placeholders={"target": target, "domain": DOMAIN}, ) config_entries.extend(device_entries) else: raise HomeAssistantError( - f"Device '{target}' not found in device registry" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"target": target}, ) - coordinators = list[BlinkUpdateCoordinator]() + + coordinators: list[BlinkUpdateCoordinator] = [] for config_entry in config_entries: if config_entry.state != ConfigEntryState.LOADED: - raise HomeAssistantError(f"{config_entry.title} is not loaded") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) return coordinators @@ -85,24 +94,36 @@ async def async_setup_services(hass: HomeAssistant) -> None: camera_name = call.data[CONF_NAME] video_path = call.data[CONF_FILENAME] if not hass.config.is_allowed_path(video_path): - _LOGGER.error("Can't write %s, no access to path!", video_path) - return - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": video_path}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): all_cameras = coordinator.api.cameras if camera_name in all_cameras: try: await all_cameras[camera_name].video_to_file(video_path) except OSError as err: - _LOGGER.error("Can't write image to file: %s", err) + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err async def async_handle_save_recent_clips_service(call: ServiceCall) -> None: """Save multiple recent clips to output directory.""" camera_name = call.data[CONF_NAME] clips_dir = call.data[CONF_FILE_PATH] if not hass.config.is_allowed_path(clips_dir): - _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) - return - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": clips_dir}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): all_cameras = coordinator.api.cameras if camera_name in all_cameras: try: @@ -110,11 +131,15 @@ async def async_setup_services(hass: HomeAssistant) -> None: output_dir=clips_dir ) except OSError as err: - _LOGGER.error("Can't write recent clips to directory: %s", err) + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err async def send_pin(call: ServiceCall): """Call blink to send new pin.""" - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.auth.send_auth_key( coordinator.api, call.data[CONF_PIN], @@ -122,7 +147,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def blink_refresh(call: ServiceCall): """Call blink to refresh info.""" - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.refresh(force_cache=True) # Register all the above services diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index c29c4c765b7..f47f72acb9c 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -101,5 +101,22 @@ } } } + }, + "exceptions": { + "invalid_device": { + "message": "Device '{target}' is not a {domain} device" + }, + "device_not_found": { + "message": "Device '{target}' not found in device registry" + }, + "no_path": { + "message": "Can't write to directory {target}, no access to path!" + }, + "cant_write": { + "message": "Can't write to file" + }, + "not_loaded": { + "message": "{target} is not loaded" + } } } diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index 438b47f38c5..ccc326dac1f 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_PIN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -58,7 +58,7 @@ async def test_refresh_service_calls( assert mock_blink_api.refresh.call_count == 2 - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_REFRESH, @@ -66,8 +66,6 @@ async def test_refresh_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - async def test_video_service_calls( hass: HomeAssistant, @@ -90,18 +88,17 @@ async def test_video_service_calls( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - assert "no access to path!" in caplog.text + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=True) caplog.clear() @@ -118,7 +115,7 @@ async def test_video_service_calls( ) mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SAVE_VIDEO, @@ -130,22 +127,19 @@ async def test_video_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - assert "Can't write image" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=False) @@ -171,18 +165,17 @@ async def test_picture_service_calls( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - assert "no access to path!" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=True) mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} @@ -202,21 +195,20 @@ async def test_picture_service_calls( mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( side_effect=OSError ) - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - assert "Can't write recent clips to directory" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SAVE_RECENT_CLIPS, @@ -228,8 +220,6 @@ async def test_picture_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - async def test_pin_service_calls( hass: HomeAssistant, @@ -259,7 +249,7 @@ async def test_pin_service_calls( ) assert mock_blink_api.auth.send_auth_key.assert_awaited_once - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SEND_PIN, @@ -267,8 +257,6 @@ async def test_pin_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - @pytest.mark.parametrize( ("service", "params"), @@ -325,7 +313,7 @@ async def test_service_called_with_non_blink_device( parameters = {ATTR_DEVICE_ID: [device_entry.id]} parameters.update(params) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, service, @@ -333,8 +321,6 @@ async def test_service_called_with_non_blink_device( blocking=True, ) - assert f"Device '{device_entry.id}' is not a blink device" in str(execinfo) - @pytest.mark.parametrize( ("service", "params"), @@ -382,12 +368,10 @@ async def test_service_called_with_unloaded_entry( parameters = {ATTR_DEVICE_ID: [device_entry.id]} parameters.update(params) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, service, parameters, blocking=True, ) - - assert "Mock Title is not loaded" in str(execinfo) From 00e57ab9a4f8660e27019d4c03f989c3277a55c8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 22:43:34 +0100 Subject: [PATCH 359/643] Use deprecated_class decorator in deprecated YAML loader classes (#104835) --- homeassistant/util/yaml/loader.py | 20 +++----------------- tests/util/yaml/test_init.py | 15 +++++++++------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index e8f4a734bdb..275a51cd760 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -23,7 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.frame import report +from homeassistant.helpers.deprecation import deprecated_class from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -137,17 +137,10 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets +@deprecated_class("FastSafeLoader") class SafeLoader(FastSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - report( - "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - FastSafeLoader.__init__(*args, **kwargs) - class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -158,17 +151,10 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets +@deprecated_class("PythonSafeLoader") class SafeLineLoader(PythonSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - report( - "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - PythonSafeLoader.__init__(*args, **kwargs) - LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index c4e5c58e235..3a2d9b3734d 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -590,7 +590,7 @@ async def test_loading_actual_file_with_syntax_error( def mock_integration_frame() -> Generator[Mock, None, None]: """Mock as if we're calling code from inside an integration.""" correct_frame = Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", + filename="/home/paulus/.homeassistant/custom_components/hue/light.py", lineno="23", line="self.light.is_on", ) @@ -614,12 +614,12 @@ def mock_integration_frame() -> Generator[Mock, None, None]: @pytest.mark.parametrize( - ("loader_class", "message"), + ("loader_class", "new_class"), [ - (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), + (yaml.loader.SafeLoader, "FastSafeLoader"), ( yaml.loader.SafeLineLoader, - "'SafeLineLoader' instead of 'PythonSafeLoader'", + "PythonSafeLoader", ), ], ) @@ -628,14 +628,17 @@ async def test_deprecated_loaders( mock_integration_frame: Mock, caplog: pytest.LogCaptureFixture, loader_class, - message: str, + new_class: str, ) -> None: """Test instantiating the deprecated yaml loaders logs a warning.""" with pytest.raises(TypeError), patch( "homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set() ): loader_class() - assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text + assert ( + f"{loader_class.__name__} was called from hue, this is a deprecated class. " + f"Use {new_class} instead" + ) in caplog.text def test_string_annotated(try_both_loaders) -> None: From 7767bb328d474e60d101b26dd5d30faad4c1eea6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Nov 2023 23:42:51 +0100 Subject: [PATCH 360/643] Filter out zero readings for DSMR enery sensors (#104843) --- homeassistant/components/dsmr/sensor.py | 4 ++++ tests/components/dsmr/test_sensor.py | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index f56e2c3ed33..0fa04dee489 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -799,6 +799,10 @@ class DSMREntity(SensorEntity): float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) ) + # Make sure we do not return a zero value for an energy sensor + if not value and self.state_class == SensorStateClass.TOTAL_INCREASING: + return None + return value @staticmethod diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 1b7f8efb201..0c71525be48 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -10,6 +10,8 @@ from decimal import Decimal from itertools import chain, repeat from unittest.mock import DEFAULT, MagicMock +import pytest + from homeassistant import config_entries from homeassistant.components.sensor import ( ATTR_OPTIONS, @@ -22,6 +24,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -308,7 +311,17 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) -async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: +@pytest.mark.parametrize( + ("value", "state"), + [ + (Decimal(745.690), "745.69"), + (Decimal(745.695), "745.695"), + (Decimal(0.000), STATE_UNKNOWN), + ], +) +async def test_v5_meter( + hass: HomeAssistant, dsmr_connection_fixture, value: Decimal, state: str +) -> None: """Test if v5 meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -335,7 +348,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": value, "unit": "m3"}, ], ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( @@ -371,7 +384,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == "745.695" + assert gas_consumption.state == state assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) From 1273bc8ec4332e07aad56a0be85b43530ab33e95 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 1 Dec 2023 07:05:26 +0100 Subject: [PATCH 361/643] Let executor CI test pass under worse conditions (#104849) --- tests/util/test_executor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/util/test_executor.py b/tests/util/test_executor.py index 076864c65c4..d7731a44b7d 100644 --- a/tests/util/test_executor.py +++ b/tests/util/test_executor.py @@ -88,6 +88,10 @@ async def test_overall_timeout_reached(caplog: pytest.LogCaptureFixture) -> None iexecutor.shutdown() finish = time.monotonic() - assert finish - start < 1.3 + # Idealy execution time (finish - start) should be < 1.2 sec. + # CI tests might not run in an ideal environment and timing might + # not be accurate, so we let this test pass + # if the duration is below 3 seconds. + assert finish - start < 3.0 iexecutor.shutdown() From 7ec2980e52746328288f13fc61ae7f55d9779279 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 1 Dec 2023 07:14:13 +0100 Subject: [PATCH 362/643] Change pytest fixture scope from core fixtures (#104831) --- tests/components/conftest.py | 2 +- tests/conftest.py | 4 ++-- tests/test_bootstrap.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index c985565b1be..1ebcc864b4b 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -16,7 +16,7 @@ def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: yield -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def prevent_io() -> Generator[None, None, None]: """Fixture to prevent certain I/O from happening.""" with patch( diff --git a/tests/conftest.py b/tests/conftest.py index 4050c1cdb6a..fcd8e8b73a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -383,7 +383,7 @@ def reset_hass_threading_local_object() -> Generator[None, None, None]: ha._hass.__dict__.clear() -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def bcrypt_cost() -> Generator[None, None, None]: """Run with reduced rounds during tests, to speed up uses.""" import bcrypt @@ -1544,7 +1544,7 @@ async def mock_enable_bluetooth( await hass.async_block_till_done() -@pytest.fixture +@pytest.fixture(scope="session") def mock_bluetooth_adapters() -> Generator[None, None, None]: """Fixture to mock bluetooth adapters.""" with patch( diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index b98d3d0311f..42d679d7ce6 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -40,7 +40,7 @@ async def apply_stop_hass(stop_hass: None) -> None: """Make sure all hass are stopped.""" -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def mock_http_start_stop() -> Generator[None, None, None]: """Mock HTTP start and stop.""" with patch( From 0232c8dcb06108be351e316e1cb51938ad63f0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Maggioni?= Date: Fri, 1 Dec 2023 08:26:07 +0100 Subject: [PATCH 363/643] Add temperature to the light color mode parameter fallbacks (#86026) * Add color temperature to the color mode fallbacks * Manually add ATTR_COLOR_TEMP since ATTR_COLOR_TEMP_KELVIN is pre-parsed * Include the legacy ATTR_COLOR_TEMP attribute in the tests * Apply suggestions from code review Co-authored-by: Erik Montnemery * Add citation for McCamy's approximation formula If still existing, also see page 3 of https://www.st.com/resource/en/application_note/an5638-how-correlated-color-temperature-is-calculated-by-vd6283-stmicroelectronics.pdf * Update homeassistant/util/color.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/light/__init__.py | 39 ++++++++++++++++++++++ homeassistant/util/color.py | 12 +++++++ tests/components/light/test_init.py | 37 ++++++++++++++++++++ tests/util/test_color.py | 9 +++++ 4 files changed, 97 insertions(+) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 78cccde5890..3bb3797c284 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -500,6 +500,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_hs_to_xy(*hs_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + 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 if ColorMode.RGBW in supported_color_modes: @@ -515,6 +523,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: xy_color = params.pop(ATTR_XY_COLOR) if ColorMode.HS in supported_color_modes: @@ -529,6 +545,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) + elif ColorMode.COLOR_TEMP in supported_color_modes: + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: rgbw_color = params.pop(ATTR_RGBW_COLOR) rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) @@ -542,6 +565,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ( ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes ): @@ -558,6 +589,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) # If white is set to True, set it to the light's brightness # Add a warning in Home Assistant Core 2023.5 if the brightness is set to an diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 8e7fc3dc155..4520a62a5d8 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -576,6 +576,18 @@ def _white_levels_to_color_temperature( ), min(255, round(brightness * 255)) +def color_xy_to_temperature(x: float, y: float) -> int: + """Convert an xy color to a color temperature in Kelvin. + + Uses McCamy's approximation (https://doi.org/10.1002/col.5080170211), + close enough for uses between 2000 K and 10000 K. + """ + n = (x - 0.3320) / (0.1858 - y) + CCT = 437 * (n**3) + 3601 * (n**2) + 6861 * n + 5517 + + return int(CCT) + + def _clamp(color_component: float, minimum: float = 0, maximum: float = 255) -> float: """Clamp the given color component value between the given min and max values. diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 675057899b0..962c5500f06 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1444,6 +1444,7 @@ async def test_light_service_call_color_conversion( platform.ENTITIES.append(platform.MockLight("Test_legacy", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbw", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbww", STATE_ON)) + platform.ENTITIES.append(platform.MockLight("Test_temperature", STATE_ON)) entity0 = platform.ENTITIES[0] entity0.supported_color_modes = {light.ColorMode.HS} @@ -1470,6 +1471,9 @@ async def test_light_service_call_color_conversion( entity6 = platform.ENTITIES[6] entity6.supported_color_modes = {light.ColorMode.RGBWW} + entity7 = platform.ENTITIES[7] + entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP} + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1498,6 +1502,9 @@ async def test_light_service_call_color_conversion( state = hass.states.get(entity6.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] + state = hass.states.get(entity7.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + await hass.services.async_call( "light", "turn_on", @@ -1510,6 +1517,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 100), @@ -1530,6 +1538,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 1739, "color_temp": 575} await hass.services.async_call( "light", @@ -1543,6 +1553,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 0), @@ -1564,6 +1575,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint of the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1577,6 +1590,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (128, 0, 0), @@ -1597,6 +1611,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 6279, "color_temp": 159} await hass.services.async_call( "light", @@ -1610,6 +1626,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (255, 255, 255), @@ -1631,6 +1648,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1644,6 +1663,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.1, 0.8), @@ -1664,6 +1684,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 8645, "color_temp": 115} await hass.services.async_call( "light", @@ -1677,6 +1699,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.323, 0.329), @@ -1698,6 +1721,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1711,6 +1736,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (128, 0, 0, 64), @@ -1732,6 +1758,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3011, "color_temp": 332} await hass.services.async_call( "light", @@ -1745,6 +1773,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (255, 255, 255, 255), @@ -1766,6 +1795,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1779,6 +1810,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (128, 0, 0, 64, 32), @@ -1799,6 +1831,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3845, "color_temp": 260} await hass.services.async_call( "light", @@ -1812,6 +1846,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (255, 255, 255, 255, 255), @@ -1833,6 +1868,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (96, 44, 0, 255)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3451, "color_temp": 289} async def test_light_service_call_color_conversion_named_tuple( diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 7c5e959aabc..a7e6ba9ab46 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -270,6 +270,15 @@ def test_color_rgbw_to_rgb() -> None: assert color_util.color_rgbw_to_rgb(0, 0, 0, 127) == (127, 127, 127) +def test_color_xy_to_temperature() -> None: + """Test color_xy_to_temperature.""" + assert color_util.color_xy_to_temperature(0.5119, 0.4147) == 2136 + assert color_util.color_xy_to_temperature(0.368, 0.3686) == 4302 + assert color_util.color_xy_to_temperature(0.4448, 0.4066) == 2893 + assert color_util.color_xy_to_temperature(0.1, 0.8) == 8645 + assert color_util.color_xy_to_temperature(0.5, 0.4) == 2140 + + def test_color_rgb_to_hex() -> None: """Test color_rgb_to_hex.""" assert color_util.color_rgb_to_hex(255, 255, 255) == "ffffff" From 450bc8dd2ca5798e9ec537eceea2f4545bf57518 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:18:34 +0100 Subject: [PATCH 364/643] Ping to Qnap: add host field description (#104859) --- homeassistant/components/ping/strings.json | 3 +++ homeassistant/components/progettihwsw/strings.json | 3 +++ homeassistant/components/qnap/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index 31441df7736..12bc1d25c7a 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "count": "Ping count" + }, + "data_description": { + "host": "The hostname or IP address of the device you want to ping." } } }, diff --git a/homeassistant/components/progettihwsw/strings.json b/homeassistant/components/progettihwsw/strings.json index bb98d565594..d50c6f8d4e3 100644 --- a/homeassistant/components/progettihwsw/strings.json +++ b/homeassistant/components/progettihwsw/strings.json @@ -13,6 +13,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your ProgettiHWSW board." } }, "relay_modes": { diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index a5fa3c8a897..d535b9f0e87 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your QNAP device." } } }, From 742e2dbbe400d9a3f66d2c5dfd3e1605c842e0e2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 1 Dec 2023 12:26:18 +0100 Subject: [PATCH 365/643] Reolink schedule update after firmware update (#104867) --- homeassistant/components/reolink/update.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index a75af46e81e..ffd429e92ad 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -1,6 +1,7 @@ """Update entities for Reolink devices.""" from __future__ import annotations +from datetime import datetime import logging from typing import Any, Literal @@ -13,9 +14,10 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later from . import ReolinkData from .const import DOMAIN @@ -23,6 +25,8 @@ from .entity import ReolinkBaseCoordinatorEntity LOGGER = logging.getLogger(__name__) +POLL_AFTER_INSTALL = 120 + async def async_setup_entry( hass: HomeAssistant, @@ -51,6 +55,7 @@ class ReolinkUpdateEntity( super().__init__(reolink_data, reolink_data.firmware_coordinator) self._attr_unique_id = f"{self._host.unique_id}" + self._cancel_update: CALLBACK_TYPE | None = None @property def installed_version(self) -> str | None: @@ -100,3 +105,16 @@ class ReolinkUpdateEntity( ) from err finally: self.async_write_ha_state() + self._cancel_update = async_call_later( + self.hass, POLL_AFTER_INSTALL, self._async_update_future + ) + + async def _async_update_future(self, now: datetime | None = None) -> None: + """Request update.""" + await self.async_update() + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + await super().async_will_remove_from_hass() + if self._cancel_update is not None: + self._cancel_update() From 970751a635b8d6acbcbf96a2558487cdd4d4eebe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Dec 2023 04:44:58 -0700 Subject: [PATCH 366/643] Bump bluetooth-data-tools to 0.16.0 (#104854) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index c39c28b13f7..b4975e61507 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.15.0", + "bluetooth-data-tools==1.16.0", "dbus-fast==2.14.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 7eca285681d..db9cd9ba72c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "aioesphomeapi==19.2.1", - "bluetooth-data-tools==1.15.0", + "bluetooth-data-tools==1.16.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 9fd407b1636..a90b5a71c2d 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.15.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.16.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 6ecd4ed636e..ca46565b773 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.15.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.16.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index b18716d8020..d894b18f545 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.15.0"] + "requirements": ["bluetooth-data-tools==1.16.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7dad258068d..a219df6fd98 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.16.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 diff --git a/requirements_all.txt b/requirements_all.txt index 2d38ab1ed4d..f72fd5c1898 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.16.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d154d7b45f..5672c4a8259 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -476,7 +476,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.16.0 # homeassistant.components.bond bond-async==0.2.1 From dc708b8a1880e0013a41780cea208c15e1a0364a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 1 Dec 2023 15:58:15 +0100 Subject: [PATCH 367/643] Fix handling of unrecognized mimetypes in Synology DSM photos integration (#104848) --- homeassistant/components/synology_dsm/media_source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 16db365f708..3f30fe9b4e9 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -153,8 +153,7 @@ class SynologyPhotosMediaSource(MediaSource): ret = [] for album_item in album_items: mime_type, _ = mimetypes.guess_type(album_item.file_name) - assert isinstance(mime_type, str) - if mime_type.startswith("image/"): + if isinstance(mime_type, str) and mime_type.startswith("image/"): # Force small small thumbnails album_item.thumbnail_size = "sm" ret.append( From 5014e2f0c735c0750004c28157c83cda391fc4a7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 1 Dec 2023 16:42:50 +0100 Subject: [PATCH 368/643] Fix powerwall typing (#104875) --- homeassistant/components/powerwall/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 33395f5fe6a..8587101a42a 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -122,9 +122,9 @@ class PowerwallDataManager: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tesla Powerwall from a config entry.""" http_session = requests.Session() - ip_address = entry.data[CONF_IP_ADDRESS] + ip_address: str = entry.data[CONF_IP_ADDRESS] - password = entry.data.get(CONF_PASSWORD) + password: str | None = entry.data.get(CONF_PASSWORD) power_wall = Powerwall(ip_address, http_session=http_session) try: base_info = await hass.async_add_executor_job( @@ -184,7 +184,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _login_and_fetch_base_info( - power_wall: Powerwall, host: str, password: str + power_wall: Powerwall, host: str, password: str | None ) -> PowerwallBaseInfo: """Login to the powerwall and fetch the base info.""" if password is not None: From e724414475d1cf7edfd1431b882397c25eb079c2 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Fri, 1 Dec 2023 16:45:53 +0100 Subject: [PATCH 369/643] SamsungTV to Snapcast: add host field description (#104862) --- homeassistant/components/samsungtv/strings.json | 3 +++ homeassistant/components/sfr_box/strings.json | 3 +++ homeassistant/components/sma/strings.json | 3 +++ homeassistant/components/snapcast/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index f1f237fa4fb..c9d08f756d0 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } }, "confirm": { diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 7ea18304164..6f0001e97ce 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -26,6 +26,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, + "data_description": { + "host": "The hostname or IP address of your SFR device." + }, "description": "Setting the credentials is optional, but enables additional functionality." } } diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index f5dc6c16c88..16e5d7408c4 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -19,6 +19,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "host": "The hostname or IP address of your SMA device." + }, "description": "Enter your SMA device information.", "title": "Set up SMA Solar" } diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 0d51c7543f1..b5673910595 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your Snapcast server." + }, "title": "[%key:common::action::connect%]" } }, From a3dd2b8ea9f3f73f54905be79be9da237b07cf55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Dec 2023 10:57:09 -1000 Subject: [PATCH 370/643] Migrate to using faster monotonic_time_coarse from bluetooth-data-tools (#104882) --- .../components/bluetooth/active_update_coordinator.py | 2 +- homeassistant/components/bluetooth/active_update_processor.py | 2 +- homeassistant/components/bluetooth/base_scanner.py | 2 +- homeassistant/components/bluetooth/manager.py | 2 +- homeassistant/components/bluetooth/models.py | 3 +-- homeassistant/components/bluetooth/util.py | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index cdf51d34978..174e5c66ce8 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -9,10 +9,10 @@ import logging from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index a3f5e20a9e9..3a13dda28a8 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -9,10 +9,10 @@ import logging from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_processor import PassiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 637ebbaf867..21935dab6ef 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -14,6 +14,7 @@ from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak_retry_connector import NO_RSSI_VALUE from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name +from bluetooth_data_tools import monotonic_time_coarse from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -25,7 +26,6 @@ from homeassistant.core import ( ) from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.dt as dt_util -from homeassistant.util.dt import monotonic_time_coarse from . import models from .const import ( diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index ce047747a0c..e9f490285c9 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -16,6 +16,7 @@ from bluetooth_adapters import ( AdapterDetails, BluetoothAdapters, ) +from bluetooth_data_tools import monotonic_time_coarse from homeassistant import config_entries from homeassistant.const import EVENT_LOGGING_CHANGED @@ -27,7 +28,6 @@ from homeassistant.core import ( ) from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util.dt import monotonic_time_coarse from .advertisement_tracker import ( TRACKER_BUFFERING_WOBBLE_SECONDS, diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 1856ccd5994..1b8c12c6eb3 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -7,10 +7,9 @@ from enum import Enum from typing import TYPE_CHECKING, Final from bleak import BaseBleakClient +from bluetooth_data_tools import monotonic_time_coarse from home_assistant_bluetooth import BluetoothServiceInfoBleak -from homeassistant.util.dt import monotonic_time_coarse - if TYPE_CHECKING: from .manager import BluetoothManager diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index e78eb51a38c..f276b6b51e5 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -3,9 +3,9 @@ from __future__ import annotations from bluetooth_adapters import BluetoothAdapters from bluetooth_auto_recovery import recover_adapter +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import callback -from homeassistant.util.dt import monotonic_time_coarse from .models import BluetoothServiceInfoBleak from .storage import BluetoothStorage From 381036e46ac585f8b34c0eb91b1a9d24b9362019 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 2 Dec 2023 14:10:44 +0100 Subject: [PATCH 371/643] Reolink only fetch data for enabled entities (#104157) --- homeassistant/components/reolink/entity.py | 11 +++++++++++ homeassistant/components/reolink/host.py | 4 +++- homeassistant/components/reolink/light.py | 3 +++ homeassistant/components/reolink/number.py | 22 ++++++++++++++++++++++ homeassistant/components/reolink/select.py | 5 +++++ homeassistant/components/reolink/sensor.py | 2 ++ homeassistant/components/reolink/switch.py | 17 +++++++++++++++++ 7 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 584b380f391..8da64991c27 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -24,6 +24,7 @@ _T = TypeVar("_T") class ReolinkChannelEntityDescription(EntityDescription): """A class that describes entities for a camera channel.""" + cmd_key: str | None = None supported: Callable[[Host, int], bool] = lambda api, ch: True @@ -31,6 +32,7 @@ class ReolinkChannelEntityDescription(EntityDescription): class ReolinkHostEntityDescription(EntityDescription): """A class that describes host entities.""" + cmd_key: str | None = None supported: Callable[[Host], bool] = lambda api: True @@ -84,6 +86,15 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + if ( + self.entity_description.cmd_key is not None + and self.entity_description.cmd_key not in self._host.update_cmd_list + ): + self._host.update_cmd_list.append(self.entity_description.cmd_key) + class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f6eb4cb0e55..fe53639822f 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -60,6 +60,8 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, ) + self.update_cmd_list: list[str] = [] + self.webhook_id: str | None = None self._onvif_push_supported: bool = True self._onvif_long_poll_supported: bool = True @@ -311,7 +313,7 @@ class ReolinkHost: async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" - await self._api.get_states() + await self._api.get_states(cmd_list=self.update_cmd_list) async def disconnect(self) -> None: """Disconnect from the API, so the connection will be released.""" diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index b2d0402b1b9..8df69b156ad 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -41,6 +41,7 @@ class ReolinkLightEntityDescription( LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", + cmd_key="GetWhiteLed", translation_key="floodlight", icon="mdi:spotlight-beam", supported=lambda api, ch: api.supported(ch, "floodLight"), @@ -51,6 +52,7 @@ LIGHT_ENTITIES = ( ), ReolinkLightEntityDescription( key="ir_lights", + cmd_key="GetIrLights", translation_key="ir_lights", icon="mdi:led-off", entity_category=EntityCategory.CONFIG, @@ -60,6 +62,7 @@ LIGHT_ENTITIES = ( ), ReolinkLightEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 6a89eabba2b..aaf549453ed 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -41,6 +41,7 @@ class ReolinkNumberEntityDescription( NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="zoom", + cmd_key="GetZoomFocus", translation_key="zoom", icon="mdi:magnify", mode=NumberMode.SLIDER, @@ -53,6 +54,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="focus", + cmd_key="GetZoomFocus", translation_key="focus", icon="mdi:focus-field", mode=NumberMode.SLIDER, @@ -68,6 +70,7 @@ NUMBER_ENTITIES = ( # or when using the "light.floodlight" entity. ReolinkNumberEntityDescription( key="floodlight_brightness", + cmd_key="GetWhiteLed", translation_key="floodlight_brightness", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -80,6 +83,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="volume", + cmd_key="GetAudioCfg", translation_key="volume", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -92,6 +96,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="guard_return_time", + cmd_key="GetPtzGuard", translation_key="guard_return_time", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -105,6 +110,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="motion_sensitivity", + cmd_key="GetMdAlarm", translation_key="motion_sensitivity", icon="mdi:motion-sensor", entity_category=EntityCategory.CONFIG, @@ -117,6 +123,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_face_sensititvity", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -131,6 +138,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_person_sensititvity", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -145,6 +153,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_sensititvity", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -159,6 +168,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_pet_sensititvity", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -175,6 +185,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_animal_sensititvity", icon="mdi:paw", entity_category=EntityCategory.CONFIG, @@ -189,6 +200,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_delay", + cmd_key="GetAiAlarm", translation_key="ai_face_delay", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -205,6 +217,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_delay", + cmd_key="GetAiAlarm", translation_key="ai_person_delay", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -221,6 +234,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_delay", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_delay", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -237,6 +251,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_delay", + cmd_key="GetAiAlarm", translation_key="ai_pet_delay", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -255,6 +270,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_delay", + cmd_key="GetAiAlarm", translation_key="ai_animal_delay", icon="mdi:paw", entity_category=EntityCategory.CONFIG, @@ -271,6 +287,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_quick_reply_time", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_time", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -284,6 +301,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_left", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_left", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -297,6 +315,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_right", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_right", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -310,6 +329,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_disappear_time", + cmd_key="GetAiCfg", translation_key="auto_track_disappear_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -325,6 +345,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_stop_time", + cmd_key="GetAiCfg", translation_key="auto_track_stop_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -338,6 +359,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="day_night_switch_threshold", + cmd_key="GetIsp", translation_key="day_night_switch_threshold", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 3d75b08b5d1..eb2ea58cc40 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -44,6 +44,7 @@ class ReolinkSelectEntityDescription( SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="floodlight_mode", + cmd_key="GetWhiteLed", translation_key="floodlight_mode", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -54,6 +55,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="day_night_mode", + cmd_key="GetIsp", translation_key="day_night_mode", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, @@ -72,6 +74,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_quick_reply_message", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_message", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -84,6 +87,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_track_method", + cmd_key="GetAiCfg", translation_key="auto_track_method", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -94,6 +98,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 3a5da97dc61..5eef880fc91 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -52,6 +52,7 @@ class ReolinkHostSensorEntityDescription( SENSORS = ( ReolinkSensorEntityDescription( key="ptz_pan_position", + cmd_key="GetPtzCurPos", translation_key="ptz_pan_position", icon="mdi:pan", state_class=SensorStateClass.MEASUREMENT, @@ -64,6 +65,7 @@ SENSORS = ( HOST_SENSORS = ( ReolinkHostSensorEntityDescription( key="wifi_signal", + cmd_key="GetWifiSignal", translation_key="wifi_signal", icon="mdi:wifi", state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index fbb8922188d..352ba7a1103 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -50,6 +50,7 @@ class ReolinkNVRSwitchEntityDescription( SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="record_audio", + cmd_key="GetEnc", translation_key="record_audio", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, @@ -59,6 +60,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="siren_on_event", + cmd_key="GetAudioAlarm", translation_key="siren_on_event", icon="mdi:alarm-light", entity_category=EntityCategory.CONFIG, @@ -68,6 +70,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_tracking", + cmd_key="GetAiCfg", translation_key="auto_tracking", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -77,6 +80,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_focus", + cmd_key="GetAutoFocus", translation_key="auto_focus", icon="mdi:focus-field", entity_category=EntityCategory.CONFIG, @@ -86,6 +90,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="gaurd_return", + cmd_key="GetPtzGuard", translation_key="gaurd_return", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -95,6 +100,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -104,6 +110,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -113,6 +120,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -122,6 +130,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -131,6 +140,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, @@ -140,6 +150,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="doorbell_button_sound", + cmd_key="GetAudioCfg", translation_key="doorbell_button_sound", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -149,6 +160,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="hdr", + cmd_key="GetIsp", translation_key="hdr", icon="mdi:hdr", entity_category=EntityCategory.CONFIG, @@ -162,6 +174,7 @@ SWITCH_ENTITIES = ( NVR_SWITCH_ENTITIES = ( ReolinkNVRSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -171,6 +184,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -180,6 +194,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -189,6 +204,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -198,6 +214,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, From 06d663d33f8b1235737043de67521190c7d37a9d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 2 Dec 2023 16:35:52 +0100 Subject: [PATCH 372/643] Fix get_events name in calendar strings (#104902) --- homeassistant/components/calendar/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 57450000199..78b8407240c 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -73,7 +73,7 @@ } }, "get_events": { - "name": "Get event", + "name": "Get events", "description": "Get events on a calendar within a time range.", "fields": { "start_date_time": { From e9d4a02bb1c11cc4a78280f45eeeeedd50e74e80 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 2 Dec 2023 16:37:44 +0100 Subject: [PATCH 373/643] Adjust roku type hint (#104877) --- homeassistant/components/roku/browse_media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 4173d2f5c6e..acaf2e5adbc 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -39,7 +39,7 @@ EXPANDABLE_MEDIA_TYPES = [ MediaType.CHANNELS, ] -GetBrowseImageUrlType = Callable[[str, str, "str | None"], str] +GetBrowseImageUrlType = Callable[[str, str, "str | None"], str | None] def get_thumbnail_url_full( From 559e8dfc6935acfc8ae4612616651cfb4848efc9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 2 Dec 2023 17:57:58 +0100 Subject: [PATCH 374/643] Improve decorator type annotations [esphome] (#104878) --- .../components/esphome/bluetooth/client.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 96f1bce686a..06282749649 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from functools import partial import logging import sys -from typing import Any, TypeVar, cast +from typing import Any, Concatenate, ParamSpec, TypeVar import uuid if sys.version_info < (3, 12): @@ -60,7 +60,9 @@ CCCD_INDICATE_BYTES = b"\x02\x00" DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) -_WrapFuncType = TypeVar("_WrapFuncType", bound=Callable[..., Any]) +_ESPHomeClient = TypeVar("_ESPHomeClient", bound="ESPHomeClient") +_R = TypeVar("_R") +_P = ParamSpec("_P") def mac_to_int(address: str) -> int: @@ -68,12 +70,14 @@ def mac_to_int(address: str) -> int: return int(address.replace(":", ""), 16) -def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: +def api_error_as_bleak_error( + func: Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]] +) -> Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]]: """Define a wrapper throw esphome api errors as BleakErrors.""" async def _async_wrap_bluetooth_operation( - self: ESPHomeClient, *args: Any, **kwargs: Any - ) -> Any: + self: _ESPHomeClient, *args: _P.args, **kwargs: _P.kwargs + ) -> _R: # pylint: disable=protected-access try: return await func(self, *args, **kwargs) @@ -107,7 +111,7 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: except APIConnectionError as err: raise BleakError(str(err)) from err - return cast(_WrapFuncType, _async_wrap_bluetooth_operation) + return _async_wrap_bluetooth_operation @dataclass(slots=True) From 5106dd173c54dac06ce13038558e4b9d9cf902a2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 2 Dec 2023 19:28:56 +0100 Subject: [PATCH 375/643] Fix dsmr zero reconnect interval option could crash HA (#104900) * Fix dsmr zero interval option could crash HA * No change change the options --- homeassistant/components/dsmr/const.py | 1 - homeassistant/components/dsmr/sensor.py | 9 ++------- tests/components/dsmr/test_config_flow.py | 1 - tests/components/dsmr/test_init.py | 1 - tests/components/dsmr/test_mbus_migration.py | 2 -- tests/components/dsmr/test_sensor.py | 18 ++---------------- 6 files changed, 4 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index ec0623a9ed6..45332546195 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -12,7 +12,6 @@ LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" CONF_PROTOCOL = "protocol" -CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_PRECISION = "precision" CONF_TIME_BETWEEN_UPDATE = "time_between_update" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 0fa04dee489..b128f9d3baa 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -48,7 +48,6 @@ from .const import ( CONF_DSMR_VERSION, CONF_PRECISION, CONF_PROTOCOL, - CONF_RECONNECT_INTERVAL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, @@ -647,9 +646,7 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) except (serial.serialutil.SerialException, OSError): # Log any error while establishing connection and drop to retry @@ -663,9 +660,7 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) except CancelledError: # Reflect disconnect state in devices state by setting an # None telegram resulting in `unavailable` states diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 55395b92270..5c34fbd9e35 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -475,7 +475,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, } entry = MockConfigEntry( diff --git a/tests/components/dsmr/test_init.py b/tests/components/dsmr/test_init.py index 512e0822016..231cd65d768 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -99,7 +99,6 @@ async def test_migrate_unique_id( "port": "/dev/ttyUSB0", "dsmr_version": dsmr_version, "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", }, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 493fd93259f..99513b9a2a8 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -30,7 +30,6 @@ async def test_migrate_gas_to_mbus( "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, @@ -128,7 +127,6 @@ async def test_migrate_gas_to_mbus_exists( "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 0c71525be48..d3bfabdc0c6 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -52,7 +52,6 @@ async def test_default_setup( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -190,7 +189,6 @@ async def test_setup_only_energy( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", } entry_options = { @@ -246,7 +244,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "4", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -335,7 +332,6 @@ async def test_v5_meter( "port": "/dev/ttyUSB0", "dsmr_version": "5", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -411,7 +407,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> "port": "/dev/ttyUSB0", "dsmr_version": "5L", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -515,7 +510,6 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -717,7 +711,6 @@ async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) - "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -880,7 +873,6 @@ async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -992,7 +984,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1047,7 +1038,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "port": "/dev/ttyUSB0", "dsmr_version": "5S", "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -1122,7 +1112,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -1196,7 +1185,6 @@ async def test_tcp(hass: HomeAssistant, dsmr_connection_fixture) -> None: "dsmr_version": "2.2", "protocol": "dsmr_protocol", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1224,7 +1212,6 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1242,6 +1229,7 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - assert connection_factory.call_args_list[0][0][1] == "1234" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_connection_errors_retry( hass: HomeAssistant, dsmr_connection_fixture ) -> None: @@ -1252,7 +1240,6 @@ async def test_connection_errors_retry( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1281,6 +1268,7 @@ async def test_connection_errors_retry( assert first_fail_connection_factory.call_count >= 2, "connecting not retried" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: """If transport disconnects, the connection should be retried.""" from dsmr_parser.obis_references import ( @@ -1295,7 +1283,6 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1378,7 +1365,6 @@ async def test_gas_meter_providing_energy_reading( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } From c9306049b3dcce052a0cf26820435c30ff29c3f1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 2 Dec 2023 19:30:04 +0100 Subject: [PATCH 376/643] Correct SerialException import in dsmr, firmata, landysgyr_heat_meater and rfxtrx integrations (#104889) * Fix SerialException import in dsmr integration * Fix imports firmata, landysgyr_heat_meter, rfxtrx --- homeassistant/components/dsmr/config_flow.py | 2 +- homeassistant/components/dsmr/sensor.py | 2 +- homeassistant/components/firmata/board.py | 4 ++-- homeassistant/components/firmata/config_flow.py | 4 ++-- homeassistant/components/landisgyr_heat_meter/config_flow.py | 2 +- homeassistant/components/landisgyr_heat_meter/coordinator.py | 2 +- homeassistant/components/rfxtrx/config_flow.py | 2 +- tests/components/dsmr/test_config_flow.py | 2 +- tests/components/firmata/test_config_flow.py | 4 ++-- tests/components/landisgyr_heat_meter/test_config_flow.py | 4 ++-- tests/components/landisgyr_heat_meter/test_sensor.py | 2 +- tests/components/rfxtrx/test_config_flow.py | 2 +- 12 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 3b32d354766..86a7bee9ef1 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -116,7 +116,7 @@ class DSMRConnection: try: transport, protocol = await asyncio.create_task(reader_factory()) - except (serial.serialutil.SerialException, OSError): + except (serial.SerialException, OSError): LOGGER.exception("Error connecting to DSMR") return False diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index b128f9d3baa..c280c1359ba 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -648,7 +648,7 @@ async def async_setup_entry( # throttle reconnect attempts await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) - except (serial.serialutil.SerialException, OSError): + except (serial.SerialException, OSError): # Log any error while establishing connection and drop to retry # connection wait LOGGER.exception("Error connecting to DSMR") diff --git a/homeassistant/components/firmata/board.py b/homeassistant/components/firmata/board.py index c309676c8d6..233388d5013 100644 --- a/homeassistant/components/firmata/board.py +++ b/homeassistant/components/firmata/board.py @@ -65,12 +65,12 @@ class FirmataBoard: except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", self.name, err) return False - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", self.name, err ) return False - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", self.name, diff --git a/homeassistant/components/firmata/config_flow.py b/homeassistant/components/firmata/config_flow.py index 8aa4cfb836c..f5b7cb5af40 100644 --- a/homeassistant/components/firmata/config_flow.py +++ b/homeassistant/components/firmata/config_flow.py @@ -41,12 +41,12 @@ class FirmataFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", name, err) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", name, err ) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", name, err ) diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index 4f7966ae90f..7d03ed2efaf 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -108,7 +108,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # validate and retrieve the model and device number for a unique id data = await self.hass.async_add_executor_job(heat_meter.read) - except (asyncio.TimeoutError, serial.serialutil.SerialException) as err: + except (asyncio.TimeoutError, serial.SerialException) as err: _LOGGER.warning("Failed read data from: %s. %s", port, err) raise CannotConnect(f"Error communicating with device: {err}") from err diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index 27231dc7b92..db265449f37 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -33,5 +33,5 @@ class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): try: async with asyncio.timeout(ULTRAHEAT_TIMEOUT): return await self.hass.async_add_executor_job(self.api.read) - except (FileNotFoundError, serial.serialutil.SerialException) as err: + except (FileNotFoundError, serial.SerialException) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 54a60d34229..12b9290af99 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -643,7 +643,7 @@ def _test_transport(host: str | None, port: int | None, device: str | None) -> b else: try: conn = rfxtrxmod.PySerialTransport(device) - except serial.serialutil.SerialException: + except serial.SerialException: return False if conn.serial is None: diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 5c34fbd9e35..f7f490ba0dd 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -335,7 +335,7 @@ async def test_setup_serial_fail( # override the mock to have it fail the first time and succeed after first_fail_connection_factory = AsyncMock( return_value=(transport, protocol), - side_effect=chain([serial.serialutil.SerialException], repeat(DEFAULT)), + side_effect=chain([serial.SerialException], repeat(DEFAULT)), ) assert result["type"] == "form" diff --git a/tests/components/firmata/test_config_flow.py b/tests/components/firmata/test_config_flow.py index d4bf159c5c9..474455fc164 100644 --- a/tests/components/firmata/test_config_flow.py +++ b/tests/components/firmata/test_config_flow.py @@ -31,7 +31,7 @@ async def test_import_cannot_connect_serial(hass: HomeAssistant) -> None: with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -48,7 +48,7 @@ async def test_import_cannot_connect_serial_timeout(hass: HomeAssistant) -> None with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialTimeoutException, + side_effect=serial.SerialTimeoutException, ): result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index b58c91f8f16..19338d8d576 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -104,7 +104,7 @@ async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> No async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: """Test manual entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -135,7 +135,7 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: """Test select from list entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException port = mock_serial_port() result = await hass.config_entries.flow.async_init( diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 5ed2a397ccd..f05d12e49a2 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -150,7 +150,7 @@ async def test_exception_on_polling( assert state.state == "123.0" # Now 'disable' the connection and wait for polling and see if it fails - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException freezer.tick(POLLING_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 4562bf928c8..e5a5c73de39 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -216,7 +216,7 @@ async def test_setup_network_fail(transport_mock, hass: HomeAssistant) -> None: @patch("serial.tools.list_ports.comports", return_value=[com_port()]) @patch( "homeassistant.components.rfxtrx.rfxtrxmod.PySerialTransport.connect", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ) async def test_setup_serial_fail(com_mock, connect_mock, hass: HomeAssistant) -> None: """Test setup serial failed connection.""" From 7480945465be50cea0da9cbb9956be301db01413 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 2 Dec 2023 22:44:49 +0100 Subject: [PATCH 377/643] Add number entities for program temperature in ViCare integration (#103960) * add number platform * Update .coveragerc * reset default value * fix default value * cast Any value to float * Apply suggestions from code review * Update strings.json * add translation keys * remove obsolete unique id handling * add eco program * reset type * remove name from entity * Update strings.json * Update strings.json * rename * Apply suggestions from code review Co-authored-by: G Johansson * retype getter * adjust interface * use device classes * check setter * Revert "check setter" This reverts commit 360e33315987c086d923992a4ced888886636627. * remove eco entity * Update strings.json * add eco sensor entity * Revert "add eco sensor entity" This reverts commit d64b38c548d0cdc56571b7c75fa026b1bb755f42. * Update strings.json --------- Co-authored-by: G Johansson --- homeassistant/components/vicare/__init__.py | 2 +- .../components/vicare/binary_sensor.py | 3 ++ homeassistant/components/vicare/number.py | 45 ++++++++++++++++++- homeassistant/components/vicare/strings.json | 3 -- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 76de3a8a7ac..2e3284c37c4 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -41,7 +41,7 @@ _TOKEN_FILENAME = "vicare_token.save" class ViCareRequiredKeysMixin: """Mixin for required keys.""" - value_getter: Callable[[Device], bool] + value_getter: Callable[[Device], Any] @dataclass() diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 525099e7d4e..95a4bcdc9f0 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -1,6 +1,7 @@ """Viessmann ViCare sensor device.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass import logging @@ -40,6 +41,8 @@ class ViCareBinarySensorEntityDescription( ): """Describes ViCare binary sensor entity.""" + value_getter: Callable[[PyViCareDevice], bool] + CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 5511f2a5294..965b5a619fc 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -19,7 +19,11 @@ from PyViCare.PyViCareUtils import ( ) from requests.exceptions import ConnectionError as RequestConnectionError -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant @@ -37,6 +41,7 @@ _LOGGER = logging.getLogger(__name__) class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare number entity.""" + value_getter: Callable[[PyViCareDevice], float] value_setter: Callable[[PyViCareDevice, float], Any] | None = None min_value_getter: Callable[[PyViCareDevice], float | None] | None = None max_value_getter: Callable[[PyViCareDevice], float | None] | None = None @@ -49,6 +54,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( translation_key="heating_curve_shift", icon="mdi:plus-minus-variant", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getHeatingCurveShift(), value_setter=lambda api, shift: ( @@ -77,6 +83,42 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( native_max_value=3.5, native_step=0.1, ), + ViCareNumberEntityDescription( + key="normal_temperature", + translation_key="normal_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("normal"), + value_setter=lambda api, value: api.setProgramTemperature("normal", value), + min_value_getter=lambda api: api.getProgramMinTemperature("normal"), + max_value_getter=lambda api: api.getProgramMaxTemperature("normal"), + stepping_getter=lambda api: api.getProgramStepping("normal"), + ), + ViCareNumberEntityDescription( + key="reduced_temperature", + translation_key="reduced_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("reduced"), + value_setter=lambda api, value: api.setProgramTemperature("reduced", value), + min_value_getter=lambda api: api.getProgramMinTemperature("reduced"), + max_value_getter=lambda api: api.getProgramMaxTemperature("reduced"), + stepping_getter=lambda api: api.getProgramStepping("reduced"), + ), + ViCareNumberEntityDescription( + key="comfort_temperature", + translation_key="comfort_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("comfort"), + value_setter=lambda api, value: api.setProgramTemperature("comfort", value), + min_value_getter=lambda api: api.getProgramMinTemperature("comfort"), + max_value_getter=lambda api: api.getProgramMaxTemperature("comfort"), + stepping_getter=lambda api: api.getProgramStepping("comfort"), + ), ) @@ -149,6 +191,7 @@ class ViCareNumber(ViCareEntity, NumberEntity): self._attr_native_value = self.entity_description.value_getter( self._api ) + if min_value := _get_value( self.entity_description.min_value_getter, self._api ): diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 47ee60b2ea8..6c08215a9c1 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -80,9 +80,6 @@ }, "comfort_temperature": { "name": "Comfort temperature" - }, - "eco_temperature": { - "name": "Eco temperature" } }, "sensor": { From b48ad268b52942df38640e21d3ef273d0fe33e8a Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sat, 2 Dec 2023 22:04:37 +0000 Subject: [PATCH 378/643] Add alarm sensor to Aurora ABB (#104503) * Add new sensors * Add strings * Fix tests * Only add alarm sensor, & refactor * Address review comments * Address review comments. * Fix ruff --- .../components/aurora_abb_powerone/__init__.py | 2 ++ .../components/aurora_abb_powerone/sensor.py | 10 ++++++++++ .../components/aurora_abb_powerone/strings.json | 7 +++++-- tests/components/aurora_abb_powerone/test_sensor.py | 12 +++++++++--- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 43e3bd2ad5c..39abba4ada5 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -76,6 +76,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): power_watts = self.client.measure(3, True) temperature_c = self.client.measure(21) energy_wh = self.client.cumulated_energy(5) + [alarm, *_] = self.client.alarms() except AuroraTimeoutError: self.available = False _LOGGER.debug("No response from inverter (could be dark)") @@ -86,6 +87,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): data["instantaneouspower"] = round(power_watts, 1) data["temp"] = round(temperature_c, 1) data["totalenergy"] = round(energy_wh / 1000, 2) + data["alarm"] = alarm self.available = True finally: diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 0e7d0c06a4e..80b0fd656b6 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -5,6 +5,8 @@ from collections.abc import Mapping import logging from typing import Any +from aurorapy.mapping import Mapping as AuroraMapping + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -36,8 +38,16 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +ALARM_STATES = list(AuroraMapping.ALARM_STATES.values()) SENSOR_TYPES = [ + SensorEntityDescription( + key="alarm", + device_class=SensorDeviceClass.ENUM, + options=ALARM_STATES, + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="alarm", + ), SensorEntityDescription( key="instantaneouspower", device_class=SensorDeviceClass.POWER, diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index 50b6e0db502..63ea1cfefd4 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -21,11 +21,14 @@ }, "entity": { "sensor": { + "alarm": { + "name": "Alarm status" + }, "power_output": { - "name": "Power Output" + "name": "Power output" }, "total_energy": { - "name": "Total Energy" + "name": "Total energy" } } } diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 61521c49b79..a78682ced6d 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -62,6 +62,8 @@ async def test_sensors(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns, + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", @@ -102,6 +104,8 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) # sun is up with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=_simulated_returns, @@ -133,7 +137,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=AuroraTimeoutError("No response after 3 tries"), - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 2) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -145,7 +149,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=_simulated_returns, - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 4) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -159,7 +163,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=AuroraError("No response after 10 seconds"), - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 6) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -174,6 +178,8 @@ async def test_sensor_unknown_error(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=AuroraError("another error"), + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch("serial.Serial.isOpen", return_value=True): mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) From 7a9c3819e09ab185e270763a4c66751e44cbb905 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 2 Dec 2023 23:07:06 +0100 Subject: [PATCH 379/643] Add MELCloud token refresh upon firmware upgrade (#104391) * Adding initial setup * Update homeassistant/components/melcloud/__init__.py Co-authored-by: G Johansson * Adding ConfigEntryNotReady exception * Update homeassistant/components/melcloud/__init__.py Co-authored-by: G Johansson * Update homeassistant/components/melcloud/config_flow.py Co-authored-by: G Johansson * Update homeassistant/components/melcloud/__init__.py Co-authored-by: G Johansson * Placing exception handling in setup_entry * Expanding test cases --------- Co-authored-by: G Johansson --- homeassistant/components/melcloud/__init__.py | 32 ++-- .../components/melcloud/config_flow.py | 73 +++++++- .../components/melcloud/strings.json | 9 + tests/components/melcloud/test_config_flow.py | 157 ++++++++++++++++++ 4 files changed, 256 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 5f007f3a8e5..d1ed5cafcbf 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any -from aiohttp import ClientConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from pymelcloud import Device, get_devices from pymelcloud.atw_device import Zone import voluptuous as vol @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -66,7 +66,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with MELClooud.""" conf = entry.data - mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + try: + mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + except ClientResponseError as ex: + if isinstance(ex, ClientResponseError) and ex.code == 401: + raise ConfigEntryAuthFailed from ex + raise ConfigEntryNotReady from ex + except (asyncio.TimeoutError, ClientConnectionError) as ex: + raise ConfigEntryNotReady from ex + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -162,17 +170,13 @@ async def mel_devices_setup( ) -> dict[str, list[MelCloudDevice]]: """Query connected devices from MELCloud.""" session = async_get_clientsession(hass) - try: - async with asyncio.timeout(10): - all_devices = await get_devices( - token, - session, - conf_update_interval=timedelta(minutes=5), - device_set_debounce=timedelta(seconds=1), - ) - except (asyncio.TimeoutError, ClientConnectionError) as ex: - raise ConfigEntryNotReady() from ex - + async with asyncio.timeout(10): + all_devices = await get_devices( + token, + session, + conf_update_interval=timedelta(minutes=5), + device_set_debounce=timedelta(seconds=1), + ) wrapped_devices: dict[str, list[MelCloudDevice]] = {} for device_type, devices in all_devices.items(): wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices] diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 0ff17ea751a..b19e268a4c3 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -2,7 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from http import HTTPStatus +import logging +from typing import Any from aiohttp import ClientError, ClientResponseError import pymelcloud @@ -11,12 +14,14 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import AbortFlow, FlowResultType +from homeassistant.data_entry_flow import AbortFlow, FlowResult, FlowResultType from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + async def async_create_import_issue( hass: HomeAssistant, source: str, issue: str, success: bool = False @@ -56,6 +61,8 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + entry: config_entries.ConfigEntry | None = None + async def _create_entry(self, username: str, token: str): """Register new entry.""" await self.async_set_unique_id(username) @@ -126,3 +133,67 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if result["type"] == FlowResultType.CREATE_ENTRY: await async_create_import_issue(self.hass, self.context["source"], "", True) return result + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with MELCloud.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-authentication with MELCloud.""" + errors: dict[str, str] = {} + + if user_input is not None and self.entry: + aquired_token, errors = await self.async_reauthenticate_client(user_input) + + if not errors: + self.hass.config_entries.async_update_entry( + self.entry, + data={CONF_TOKEN: aquired_token}, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + async def async_reauthenticate_client( + self, user_input: dict[str, Any] + ) -> tuple[str | None, dict[str, str]]: + """Reauthenticate with MELCloud.""" + errors: dict[str, str] = {} + acquired_token = None + + try: + async with asyncio.timeout(10): + acquired_token = await pymelcloud.login( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + async_get_clientsession(self.hass), + ) + except (ClientResponseError, AttributeError) as err: + if isinstance(err, ClientResponseError) and err.status in ( + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + errors["base"] = "invalid_auth" + elif isinstance(err, AttributeError) and err.name == "get": + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except ( + asyncio.TimeoutError, + ClientError, + ): + errors["base"] = "cannot_connect" + + return acquired_token, errors diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index eefd5a07a8d..3abb30bf9ac 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -8,6 +8,14 @@ "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Melcloud integration needs to re-authenticate your connection details", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -16,6 +24,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." } }, diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index 8f877eb1eca..f3d49f3c0bc 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -9,7 +9,9 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.melcloud.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry @@ -287,3 +289,158 @@ async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) entry = entries[0] assert entry.data["username"] == "test-email@test-domain.com" assert entry.data["token"] == "test-token" + + +async def test_token_reauthentication( + hass: HomeAssistant, + mock_login, + mock_get_devices, +) -> None: + """Re-configuration with existing username should refresh token, if made invalid.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (asyncio.TimeoutError(), "cannot_connect"), + (AttributeError(name="get"), "invalid_auth"), + ], +) +async def test_form_errors_reauthentication( + hass: HomeAssistant, mock_login, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = error + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == reason + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (HTTPStatus.UNAUTHORIZED, "invalid_auth"), + (HTTPStatus.FORBIDDEN, "invalid_auth"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), + ], +) +async def test_client_errors_reauthentication( + hass: HomeAssistant, mock_login, mock_request_info, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = ClientResponseError(mock_request_info(), (), status=error) + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["errors"]["base"] == reason + assert result["type"] == FlowResultType.FORM + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" From dd9c22672a6b2d47c09aaf4e7091049e23decefe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Dec 2023 13:20:06 -1000 Subject: [PATCH 380/643] Refactor bluetooth scanners for better seperation of concerns (#104909) --- .../components/bluetooth/__init__.py | 8 +- .../components/bluetooth/base_scanner.py | 208 ++++++++++++------ homeassistant/components/bluetooth/scanner.py | 12 +- .../components/esphome/bluetooth/scanner.py | 7 +- .../components/ruuvi_gateway/bluetooth.py | 4 +- .../components/shelly/bluetooth/scanner.py | 7 +- tests/components/bluetooth/test_api.py | 8 +- .../components/bluetooth/test_base_scanner.py | 18 +- .../components/bluetooth/test_diagnostics.py | 4 +- tests/components/bluetooth/test_manager.py | 14 +- tests/components/bluetooth/test_models.py | 12 +- tests/components/bluetooth/test_wrappers.py | 4 +- 12 files changed, 195 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c59249e8bd5..5948d4d1358 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -59,7 +59,11 @@ from .api import ( async_set_fallback_availability_interval, async_track_unavailable, ) -from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice +from .base_scanner import ( + BaseHaScanner, + BluetoothScannerDevice, + HomeAssistantRemoteScanner, +) from .const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_ADAPTER, @@ -103,7 +107,7 @@ __all__ = [ "async_scanner_count", "async_scanner_devices_by_address", "BaseHaScanner", - "BaseHaRemoteScanner", + "HomeAssistantRemoteScanner", "BluetoothCallbackMatcher", "BluetoothChange", "BluetoothServiceInfo", diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 21935dab6ef..f7696c2e90b 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -1,14 +1,13 @@ """Base classes for HA Bluetooth scanners for bluetooth.""" from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod +import asyncio from collections.abc import Callable, Generator from contextlib import contextmanager from dataclasses import dataclass -import datetime -from datetime import timedelta import logging -from typing import Any, Final +from typing import Any, Final, final from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData @@ -24,8 +23,6 @@ from homeassistant.core import ( HomeAssistant, callback as hass_callback, ) -from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util from . import models from .const import ( @@ -35,6 +32,7 @@ from .const import ( ) from .models import HaBluetoothConnector +SCANNER_WATCHDOG_INTERVAL_SECONDS: Final = SCANNER_WATCHDOG_INTERVAL.total_seconds() MONOTONIC_TIME: Final = monotonic_time_coarse _LOGGER = logging.getLogger(__name__) @@ -48,11 +46,10 @@ class BluetoothScannerDevice: advertisement: AdvertisementData -class BaseHaScanner(ABC): - """Base class for Ha Scanners.""" +class BaseHaScanner: + """Base class for high availability BLE scanners.""" __slots__ = ( - "hass", "adapter", "connectable", "source", @@ -63,17 +60,16 @@ class BaseHaScanner(ABC): "_last_detection", "_start_time", "_cancel_watchdog", + "_loop", ) def __init__( self, - hass: HomeAssistant, source: str, adapter: str, connector: HaBluetoothConnector | None = None, ) -> None: """Initialize the scanner.""" - self.hass = hass self.connectable = False self.source = source self.connector = connector @@ -83,13 +79,20 @@ class BaseHaScanner(ABC): self.scanning = True self._last_detection = 0.0 self._start_time = 0.0 - self._cancel_watchdog: CALLBACK_TYPE | None = None + self._cancel_watchdog: asyncio.TimerHandle | None = None + self._loop: asyncio.AbstractEventLoop | None = None + + @hass_callback + def async_setup(self) -> CALLBACK_TYPE: + """Set up the scanner.""" + self._loop = asyncio.get_running_loop() + return self._unsetup @hass_callback def _async_stop_scanner_watchdog(self) -> None: """Stop the scanner watchdog.""" if self._cancel_watchdog: - self._cancel_watchdog() + self._cancel_watchdog.cancel() self._cancel_watchdog = None @hass_callback @@ -97,12 +100,22 @@ class BaseHaScanner(ABC): """If something has restarted or updated, we need to restart the scanner.""" self._start_time = self._last_detection = MONOTONIC_TIME() if not self._cancel_watchdog: - self._cancel_watchdog = async_track_time_interval( - self.hass, - self._async_scanner_watchdog, - SCANNER_WATCHDOG_INTERVAL, - name=f"{self.name} Bluetooth scanner watchdog", - ) + self._schedule_watchdog() + + def _schedule_watchdog(self) -> None: + """Schedule the watchdog.""" + loop = self._loop + assert loop is not None + self._cancel_watchdog = loop.call_at( + loop.time() + SCANNER_WATCHDOG_INTERVAL_SECONDS, + self._async_call_scanner_watchdog, + ) + + @final + def _async_call_scanner_watchdog(self) -> None: + """Call the scanner watchdog and schedule the next one.""" + self._async_scanner_watchdog() + self._schedule_watchdog() @hass_callback def _async_watchdog_triggered(self) -> bool: @@ -116,7 +129,7 @@ class BaseHaScanner(ABC): return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT @hass_callback - def _async_scanner_watchdog(self, now: datetime.datetime) -> None: + def _async_scanner_watchdog(self) -> None: """Check if the scanner is running. Override this method if you need to do something else when the watchdog @@ -135,6 +148,10 @@ class BaseHaScanner(ABC): return self.scanning = not self._connecting + @hass_callback + def _unsetup(self) -> None: + """Unset up the scanner.""" + @contextmanager def connecting(self) -> Generator[None, None, None]: """Context manager to track connecting state.""" @@ -183,7 +200,7 @@ class BaseHaScanner(ABC): class BaseHaRemoteScanner(BaseHaScanner): - """Base class for a Home Assistant remote BLE scanner.""" + """Base class for a high availability remote BLE scanner.""" __slots__ = ( "_new_info_callback", @@ -191,12 +208,11 @@ class BaseHaRemoteScanner(BaseHaScanner): "_discovered_device_timestamps", "_details", "_expire_seconds", - "_storage", + "_cancel_track", ) def __init__( self, - hass: HomeAssistant, scanner_id: str, name: str, new_info_callback: Callable[[BluetoothServiceInfoBleak], None], @@ -204,7 +220,7 @@ class BaseHaRemoteScanner(BaseHaScanner): connectable: bool, ) -> None: """Initialize the scanner.""" - super().__init__(hass, scanner_id, name, connector) + super().__init__(scanner_id, name, connector) self._new_info_callback = new_info_callback self._discovered_device_advertisement_datas: dict[ str, tuple[BLEDevice, AdvertisementData] @@ -215,55 +231,37 @@ class BaseHaRemoteScanner(BaseHaScanner): # Scanners only care about connectable devices. The manager # will handle taking care of availability for non-connectable devices self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - assert models.MANAGER is not None - self._storage = models.MANAGER.storage + self._cancel_track: asyncio.TimerHandle | None = None + + def _cancel_expire_devices(self) -> None: + """Cancel the expiration of old devices.""" + if self._cancel_track: + self._cancel_track.cancel() + self._cancel_track = None + + @hass_callback + def _unsetup(self) -> None: + """Unset up the scanner.""" + self._async_stop_scanner_watchdog() + self._cancel_expire_devices() @hass_callback def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" - if history := self._storage.async_get_advertisement_history(self.source): - self._discovered_device_advertisement_datas = ( - history.discovered_device_advertisement_datas - ) - self._discovered_device_timestamps = history.discovered_device_timestamps - # Expire anything that is too old - self._async_expire_devices(dt_util.utcnow()) - - cancel_track = async_track_time_interval( - self.hass, - self._async_expire_devices, - timedelta(seconds=30), - name=f"{self.name} Bluetooth scanner device expire", - ) - cancel_stop = self.hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, self._async_save_history - ) + super().async_setup() + self._schedule_expire_devices() self._async_setup_scanner_watchdog() + return self._unsetup - @hass_callback - def _cancel() -> None: - self._async_save_history() - self._async_stop_scanner_watchdog() - cancel_track() - cancel_stop() - - return _cancel + def _schedule_expire_devices(self) -> None: + """Schedule the expiration of old devices.""" + loop = self._loop + assert loop is not None + self._cancel_expire_devices() + self._cancel_track = loop.call_at(loop.time() + 30, self._async_expire_devices) @hass_callback - def _async_save_history(self, event: Event | None = None) -> None: - """Save the history.""" - self._storage.async_set_advertisement_history( - self.source, - DiscoveredDeviceAdvertisementData( - self.connectable, - self._expire_seconds, - self._discovered_device_advertisement_datas, - self._discovered_device_timestamps, - ), - ) - - @hass_callback - def _async_expire_devices(self, _datetime: datetime.datetime) -> None: + def _async_expire_devices(self) -> None: """Expire old devices.""" now = MONOTONIC_TIME() expired = [ @@ -274,6 +272,7 @@ class BaseHaRemoteScanner(BaseHaScanner): for address in expired: del self._discovered_device_advertisement_datas[address] del self._discovered_device_timestamps[address] + self._schedule_expire_devices() @property def discovered_devices(self) -> list[BLEDevice]: @@ -395,9 +394,6 @@ class BaseHaRemoteScanner(BaseHaScanner): """Return diagnostic information about the scanner.""" now = MONOTONIC_TIME() return await super().async_diagnostics() | { - "storage": self._storage.async_get_advertisement_history_as_dict( - self.source - ), "connectable": self.connectable, "discovered_device_timestamps": self._discovered_device_timestamps, "time_since_last_device_detection": { @@ -405,3 +401,79 @@ class BaseHaRemoteScanner(BaseHaScanner): for address, timestamp in self._discovered_device_timestamps.items() }, } + + +class HomeAssistantRemoteScanner(BaseHaRemoteScanner): + """Home Assistant remote BLE scanner. + + This is the only object that should know about + the hass object. + """ + + __slots__ = ( + "hass", + "_storage", + "_cancel_stop", + ) + + def __init__( + self, + hass: HomeAssistant, + scanner_id: str, + name: str, + new_info_callback: Callable[[BluetoothServiceInfoBleak], None], + connector: HaBluetoothConnector | None, + connectable: bool, + ) -> None: + """Initialize the scanner.""" + self.hass = hass + assert models.MANAGER is not None + self._storage = models.MANAGER.storage + self._cancel_stop: CALLBACK_TYPE | None = None + super().__init__(scanner_id, name, new_info_callback, connector, connectable) + + @hass_callback + def async_setup(self) -> CALLBACK_TYPE: + """Set up the scanner.""" + super().async_setup() + if history := self._storage.async_get_advertisement_history(self.source): + self._discovered_device_advertisement_datas = ( + history.discovered_device_advertisement_datas + ) + self._discovered_device_timestamps = history.discovered_device_timestamps + # Expire anything that is too old + self._async_expire_devices() + + self._cancel_stop = self.hass.bus.async_listen( + EVENT_HOMEASSISTANT_STOP, self._async_save_history + ) + return self._unsetup + + @hass_callback + def _unsetup(self) -> None: + super()._unsetup() + self._async_save_history() + if self._cancel_stop: + self._cancel_stop() + self._cancel_stop = None + + @hass_callback + def _async_save_history(self, event: Event | None = None) -> None: + """Save the history.""" + self._storage.async_set_advertisement_history( + self.source, + DiscoveredDeviceAdvertisementData( + self.connectable, + self._expire_seconds, + self._discovered_device_advertisement_datas, + self._discovered_device_timestamps, + ), + ) + + async def async_diagnostics(self) -> dict[str, Any]: + """Return diagnostic information about the scanner.""" + diag = await super().async_diagnostics() + diag["storage"] = self._storage.async_get_advertisement_history_as_dict( + self.source + ) + return diag diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 896d9dc7958..712fe1c0d9a 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from datetime import datetime import logging import platform from typing import Any @@ -19,7 +18,7 @@ from bleak_retry_connector import restore_discoveries from bluetooth_adapters import DEFAULT_ADDRESS from dbus_fast import InvalidMessageError -from homeassistant.core import HomeAssistant, callback as hass_callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util.package import is_docker_env @@ -133,12 +132,13 @@ class HaScanner(BaseHaScanner): """Init bluetooth discovery.""" self.mac_address = address source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL - super().__init__(hass, source, adapter) + super().__init__(source, adapter) self.connectable = True self.mode = mode self._start_stop_lock = asyncio.Lock() self._new_info_callback = new_info_callback self.scanning = False + self.hass = hass @property def discovered_devices(self) -> list[BLEDevice]: @@ -153,11 +153,13 @@ class HaScanner(BaseHaScanner): return self.scanner.discovered_devices_and_advertisement_data @hass_callback - def async_setup(self) -> None: + def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" + super().async_setup() self.scanner = create_bleak_scanner( self._async_detection_callback, self.mode, self.adapter ) + return self._unsetup async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" @@ -314,7 +316,7 @@ class HaScanner(BaseHaScanner): await restore_discoveries(self.scanner, self.adapter) @hass_callback - def _async_scanner_watchdog(self, now: datetime) -> None: + def _async_scanner_watchdog(self) -> None: """Check if the scanner is running.""" if not self._async_watchdog_triggered(): return diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index a54e7af59a6..b4fb12210d3 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -7,11 +7,14 @@ from bluetooth_data_tools import ( parse_advertisement_data_tuple, ) -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner +from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, + HomeAssistantRemoteScanner, +) from homeassistant.core import callback -class ESPHomeScanner(BaseHaRemoteScanner): +class ESPHomeScanner(HomeAssistantRemoteScanner): """Scanner for esphome.""" __slots__ = () diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index 47a9bbfdde0..8a154bca019 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -10,7 +10,7 @@ from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, - BaseHaRemoteScanner, + HomeAssistantRemoteScanner, async_get_advertisement_callback, async_register_scanner, ) @@ -22,7 +22,7 @@ from .coordinator import RuuviGatewayUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class RuuviGatewayScanner(BaseHaRemoteScanner): +class RuuviGatewayScanner(HomeAssistantRemoteScanner): """Scanner for Ruuvi Gateway.""" def __init__( diff --git a/homeassistant/components/shelly/bluetooth/scanner.py b/homeassistant/components/shelly/bluetooth/scanner.py index 7c0dc3c792a..3ada1ce55f5 100644 --- a/homeassistant/components/shelly/bluetooth/scanner.py +++ b/homeassistant/components/shelly/bluetooth/scanner.py @@ -6,13 +6,16 @@ from typing import Any from aioshelly.ble import parse_ble_scan_result_event from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner +from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, + HomeAssistantRemoteScanner, +) from homeassistant.core import callback from ..const import LOGGER -class ShellyBLEScanner(BaseHaRemoteScanner): +class ShellyBLEScanner(HomeAssistantRemoteScanner): """Scanner for shelly.""" @callback diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 63b60c8f487..30e9554f2af 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -7,9 +7,9 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_scanner_by_source, async_scanner_devices_by_address, ) @@ -27,7 +27,7 @@ from . import ( async def test_scanner_by_source(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test we can get a scanner by source.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") + hci2_scanner = FakeScanner("hci2", "hci2") cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) assert async_scanner_by_source(hass, "hci2") is hci2_scanner @@ -46,7 +46,7 @@ async def test_async_scanner_devices_by_address_connectable( """Test getting scanner devices by address with connectable devices.""" manager = _get_manager() - class FakeInjectableScanner(BaseHaRemoteScanner): + class FakeInjectableScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -135,7 +135,7 @@ async def test_async_scanner_devices_by_address_non_connectable( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeStaticScanner(hass, "esp32", "esp32", connector) + scanner = FakeStaticScanner("esp32", "esp32", connector) cancel = manager.async_register_scanner(scanner, False) assert scanner.discovered_devices_and_advertisement_data == { diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 31d90a6e93d..a39f18e037e 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -13,8 +13,8 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, storage, ) from homeassistant.components.bluetooth.advertisement_tracker import ( @@ -89,7 +89,7 @@ async def test_remote_scanner( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -173,7 +173,7 @@ async def test_remote_scanner_expires_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -248,7 +248,7 @@ async def test_remote_scanner_expires_non_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -346,7 +346,7 @@ async def test_base_scanner_connecting_behavior( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -418,7 +418,7 @@ async def test_restore_history_remote_adapter( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = BaseHaRemoteScanner( + scanner = HomeAssistantRemoteScanner( hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", @@ -434,7 +434,7 @@ async def test_restore_history_remote_adapter( cancel() unsetup() - scanner = BaseHaRemoteScanner( + scanner = HomeAssistantRemoteScanner( hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", @@ -470,7 +470,7 @@ async def test_device_with_ten_minute_advertising_interval( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -592,7 +592,7 @@ async def test_scanner_stops_responding( """Test we mark a scanner are not scanning when it stops responding.""" manager = _get_manager() - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): """A fake remote scanner.""" def inject_advertisement( diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 0e8b2b54f06..8625283266e 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -7,8 +7,8 @@ from bluetooth_adapters import DEFAULT_ADDRESS from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, ) from homeassistant.core import HomeAssistant @@ -442,7 +442,7 @@ async def test_diagnostics_remote_adapter( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 6c89074e471..361f0cd008f 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -12,12 +12,12 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BluetoothChange, BluetoothScanningMode, BluetoothServiceInfo, BluetoothServiceInfoBleak, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_ble_device_from_address, async_get_advertisement_callback, async_get_fallback_availability_interval, @@ -56,7 +56,7 @@ from tests.common import async_fire_time_changed, load_fixture @pytest.fixture def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci0 scanner.""" - hci0_scanner = FakeScanner(hass, "hci0", "hci0") + hci0_scanner = FakeScanner("hci0", "hci0") cancel = bluetooth.async_register_scanner(hass, hci0_scanner, True) yield cancel() @@ -65,7 +65,7 @@ def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: @pytest.fixture def register_hci1_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci1 scanner.""" - hci1_scanner = FakeScanner(hass, "hci1", "hci1") + hci1_scanner = FakeScanner("hci1", "hci1") cancel = bluetooth.async_register_scanner(hass, hci1_scanner, True) yield cancel() @@ -562,7 +562,7 @@ async def test_switching_adapters_when_one_goes_away( ) -> None: """Test switching adapters when one goes away.""" cancel_hci2 = bluetooth.async_register_scanner( - hass, FakeScanner(hass, "hci2", "hci2"), True + hass, FakeScanner("hci2", "hci2"), True ) address = "44:44:33:11:23:45" @@ -612,7 +612,7 @@ async def test_switching_adapters_when_one_stop_scanning( hass: HomeAssistant, enable_bluetooth: None, register_hci0_scanner: None ) -> None: """Test switching adapters when stops scanning.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") + hci2_scanner = FakeScanner("hci2", "hci2") cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) address = "44:44:33:11:23:45" @@ -704,7 +704,7 @@ async def test_goes_unavailable_connectable_only_and_recovers( BluetoothScanningMode.ACTIVE, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -877,7 +877,7 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( BluetoothScanningMode.ACTIVE, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 746f52537cb..1d07ab75a48 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -10,9 +10,9 @@ from bleak.backends.scanner import AdvertisementData import pytest from homeassistant.components.bluetooth import ( - BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, ) from homeassistant.components.bluetooth.wrappers import ( HaBleakClientWrapper, @@ -158,7 +158,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100 ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -271,7 +271,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -336,7 +336,7 @@ async def test_ble_device_with_proxy_clear_cache( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -439,7 +439,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -553,7 +553,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index f69f8971479..d3c2e1b54db 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -12,9 +12,9 @@ import pytest from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BluetoothServiceInfoBleak, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_get_advertisement_callback, ) from homeassistant.components.bluetooth.usage import ( @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant from . import _get_manager, generate_advertisement_data, generate_ble_device -class FakeScanner(BaseHaRemoteScanner): +class FakeScanner(HomeAssistantRemoteScanner): """Fake scanner.""" def __init__( From a5fe68c3541f03904a58f61e679c39e8e2757bf2 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sun, 3 Dec 2023 11:15:07 +0100 Subject: [PATCH 381/643] Bump python-holidays to 0.37 (#104937) --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index c7c993e70d0..dd2df87234f 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.36"] + "requirements": ["holidays==0.37"] } diff --git a/requirements_all.txt b/requirements_all.txt index f72fd5c1898..c70e0784897 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.36 +holidays==0.37 # homeassistant.components.frontend home-assistant-frontend==20231130.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5672c4a8259..47b9feb0a7b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.36 +holidays==0.37 # homeassistant.components.frontend home-assistant-frontend==20231130.0 From edb52bb364130e42f5bde4074339ef57c9613c15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 00:51:34 -1000 Subject: [PATCH 382/643] Bump zeroconf to 0.128.0 (#104936) --- 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 5eb77b0c41c..8351212f0b8 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.127.0"] + "requirements": ["zeroconf==0.128.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a219df6fd98..7b788c8f78a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -57,7 +57,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.127.0 +zeroconf==0.128.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index c70e0784897..5f811f514a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2813,7 +2813,7 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.127.0 +zeroconf==0.128.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47b9feb0a7b..97a41ba9fe4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2108,7 +2108,7 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.127.0 +zeroconf==0.128.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 5d6791e412e80fb5136d0e6be71cfb8587c16c54 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 3 Dec 2023 11:53:09 +0100 Subject: [PATCH 383/643] Remove CONF_PRECISION remainder from dsmr (#104919) --- homeassistant/components/dsmr/const.py | 1 - homeassistant/components/dsmr/sensor.py | 5 +---- tests/components/dsmr/test_config_flow.py | 1 - tests/components/dsmr/test_init.py | 1 - tests/components/dsmr/test_mbus_migration.py | 2 -- tests/components/dsmr/test_sensor.py | 20 ++------------------ 6 files changed, 3 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 45332546195..4ac59372deb 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -12,7 +12,6 @@ LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" CONF_PROTOCOL = "protocol" -CONF_PRECISION = "precision" CONF_TIME_BETWEEN_UPDATE = "time_between_update" CONF_SERIAL_ID = "serial_id" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index c280c1359ba..722b8eda326 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -46,7 +46,6 @@ from homeassistant.util import Throttle from .const import ( CONF_DSMR_VERSION, - CONF_PRECISION, CONF_PROTOCOL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, @@ -790,9 +789,7 @@ class DSMREntity(SensorEntity): return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION]) with suppress(TypeError): - value = round( - float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) - ) + value = round(float(value), DEFAULT_PRECISION) # Make sure we do not return a zero value for an energy sensor if not value and self.state_class == SensorStateClass.TOTAL_INCREASING: diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index f7f490ba0dd..422bfa0c35c 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -474,7 +474,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, } entry = MockConfigEntry( diff --git a/tests/components/dsmr/test_init.py b/tests/components/dsmr/test_init.py index 231cd65d768..b42f26f4ccc 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -98,7 +98,6 @@ async def test_migrate_unique_id( data={ "port": "/dev/ttyUSB0", "dsmr_version": dsmr_version, - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", }, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 99513b9a2a8..5e31fa7a82e 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -29,7 +29,6 @@ async def test_migrate_gas_to_mbus( data={ "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, @@ -126,7 +125,6 @@ async def test_migrate_gas_to_mbus_exists( data={ "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index d3bfabdc0c6..419b562f431 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -51,7 +51,6 @@ async def test_default_setup( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -188,7 +187,6 @@ async def test_setup_only_energy( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", } entry_options = { @@ -243,7 +241,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "4", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -331,7 +328,6 @@ async def test_v5_meter( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -406,7 +402,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5L", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -509,7 +504,6 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": None, } @@ -710,7 +704,6 @@ async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": None, } @@ -872,7 +865,6 @@ async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": None, } @@ -983,7 +975,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1037,7 +1028,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5S", - "precision": 4, "serial_id": None, "serial_id_gas": None, } @@ -1111,7 +1101,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", - "precision": 4, "serial_id": None, "serial_id_gas": None, } @@ -1151,7 +1140,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") - assert active_tariff.state == "54184.6316" + assert active_tariff.state == "54184.632" assert active_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert active_tariff.attributes.get(ATTR_ICON) is None assert ( @@ -1164,7 +1153,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) active_tariff = hass.states.get("sensor.electricity_meter_energy_production_total") - assert active_tariff.state == "19981.1069" + assert active_tariff.state == "19981.107" assert ( active_tariff.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING @@ -1184,7 +1173,6 @@ async def test_tcp(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "1234", "dsmr_version": "2.2", "protocol": "dsmr_protocol", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1211,7 +1199,6 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - "port": "1234", "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1239,7 +1226,6 @@ async def test_connection_errors_retry( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1282,7 +1268,6 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1364,7 +1349,6 @@ async def test_gas_meter_providing_energy_reading( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } From 67784def1337df971d054d2fd3c6587472a6db47 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 3 Dec 2023 11:57:48 +0100 Subject: [PATCH 384/643] Only raise issue if switch used in Logitech Harmony Hub (#104941) --- homeassistant/components/harmony/switch.py | 27 ++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index 6b833df9720..2d072f11f2c 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -23,15 +23,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up harmony activity switches.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_switches", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_switches", - ) data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] activities = data.activities @@ -65,10 +56,28 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Start this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_start_activity(self._activity_name) async def async_turn_off(self, **kwargs: Any) -> None: """Stop this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_power_off() async def async_added_to_hass(self) -> None: From 244edb488be5d2df0fb250cf7e725e1209cb7e18 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sun, 3 Dec 2023 16:28:53 +0100 Subject: [PATCH 385/643] Add Holiday integration (#103795) * Add Holiday integration * Localize holiday names * Changes based on review feedback * Add tests * Add device info * Bump holidays to 0.36 * Default to Home Assistant country setting * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Update homeassistant/components/holiday/config_flow.py Co-authored-by: G Johansson * black * Move time * Stop creating duplicate holiday calendars * Set default language using python-holiday * Use common translation * Set _attr_name to None to fix friendly name * Fix location * Update homeassistant/components/holiday/__init__.py Co-authored-by: G Johansson * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Update tests/components/holiday/test_init.py Co-authored-by: G Johansson * cleanup * Set up the integration and test the state * Test that configuring more than one instance is rejected * Set default_language to user's language, fallback to country's default language * Improve tests * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Cleanup * Add next year so we don't run out * Update tests/components/holiday/test_init.py Co-authored-by: G Johansson * Cleanup * Set default language in `__init__` * Add strict typing * Change default language: HA's language `en` is `en_US` in holidays, apart from Canada * CONF_PROVINCE can be None * Fix test * Fix default_language * Refactor tests * Province can be None * Add test for translated title * Address feedback * Address feedback * Change test to use service call * Address feedback * Apply suggestions from code review Co-authored-by: G Johansson * Changes based on review feedback * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Update homeassistant/components/holiday/calendar.py Co-authored-by: G Johansson * Add a test if next event is missing * Rebase * Set device to service * Remove not needed translation key --------- Co-authored-by: G Johansson --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/holiday/__init__.py | 20 ++ homeassistant/components/holiday/calendar.py | 134 ++++++++++ .../components/holiday/config_flow.py | 99 ++++++++ homeassistant/components/holiday/const.py | 6 + .../components/holiday/manifest.json | 9 + homeassistant/components/holiday/strings.json | 19 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 4 + requirements_test_all.txt | 4 + tests/components/holiday/__init__.py | 1 + tests/components/holiday/conftest.py | 14 ++ tests/components/holiday/test_calendar.py | 229 ++++++++++++++++++ tests/components/holiday/test_config_flow.py | 128 ++++++++++ tests/components/holiday/test_init.py | 29 +++ 18 files changed, 716 insertions(+) create mode 100644 homeassistant/components/holiday/__init__.py create mode 100644 homeassistant/components/holiday/calendar.py create mode 100644 homeassistant/components/holiday/config_flow.py create mode 100644 homeassistant/components/holiday/const.py create mode 100644 homeassistant/components/holiday/manifest.json create mode 100644 homeassistant/components/holiday/strings.json create mode 100644 tests/components/holiday/__init__.py create mode 100644 tests/components/holiday/conftest.py create mode 100644 tests/components/holiday/test_calendar.py create mode 100644 tests/components/holiday/test_config_flow.py create mode 100644 tests/components/holiday/test_init.py diff --git a/.strict-typing b/.strict-typing index daa4a56dead..daf2baabf67 100644 --- a/.strict-typing +++ b/.strict-typing @@ -152,6 +152,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.holiday.* homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* diff --git a/CODEOWNERS b/CODEOWNERS index 33587dec721..e618db415c6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -522,6 +522,8 @@ build.json @home-assistant/supervisor /tests/components/hive/ @Rendili @KJonline /homeassistant/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard +/homeassistant/components/holiday/ @jrieger +/tests/components/holiday/ @jrieger /homeassistant/components/home_connect/ @DavidMStraub /tests/components/home_connect/ @DavidMStraub /homeassistant/components/home_plus_control/ @chemaaa diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py new file mode 100644 index 00000000000..224b1b01294 --- /dev/null +++ b/homeassistant/components/holiday/__init__.py @@ -0,0 +1,20 @@ +"""The Holiday integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Holiday from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py new file mode 100644 index 00000000000..bb9a332cb73 --- /dev/null +++ b/homeassistant/components/holiday/calendar.py @@ -0,0 +1,134 @@ +"""Holiday Calendar.""" +from __future__ import annotations + +from datetime import datetime + +from holidays import HolidayBase, country_holidays + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import CONF_PROVINCE, DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Holiday Calendar config entry.""" + country: str = config_entry.data[CONF_COUNTRY] + province: str | None = config_entry.data.get(CONF_PROVINCE) + language = hass.config.language + + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=language, + ) + if language == "en": + for lang in obj_holidays.supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=lang, + ) + language = lang + break + + async_add_entities( + [ + HolidayCalendarEntity( + config_entry.title, + country, + province, + language, + obj_holidays, + config_entry.entry_id, + ) + ], + True, + ) + + +class HolidayCalendarEntity(CalendarEntity): + """Representation of a Holiday Calendar element.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + name: str, + country: str, + province: str | None, + language: str, + obj_holidays: HolidayBase, + unique_id: str, + ) -> None: + """Initialize HolidayCalendarEntity.""" + self._country = country + self._province = province + self._location = name + self._language = language + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + entry_type=DeviceEntryType.SERVICE, + name=name, + ) + self._obj_holidays = obj_holidays + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + next_holiday = None + for holiday_date, holiday_name in sorted( + self._obj_holidays.items(), key=lambda x: x[0] + ): + if holiday_date >= dt_util.now().date(): + next_holiday = (holiday_date, holiday_name) + break + + if next_holiday is None: + return None + + return CalendarEvent( + summary=next_holiday[1], + start=next_holiday[0], + end=next_holiday[0], + location=self._location, + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + obj_holidays = country_holidays( + self._country, + subdiv=self._province, + years=list({start_date.year, end_date.year}), + language=self._language, + ) + + event_list: list[CalendarEvent] = [] + + for holiday_date, holiday_name in obj_holidays.items(): + if start_date.date() <= holiday_date <= end_date.date(): + event = CalendarEvent( + summary=holiday_name, + start=holiday_date, + end=holiday_date, + location=self._location, + ) + event_list.append(event) + + return event_list diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py new file mode 100644 index 00000000000..93ff2772eb8 --- /dev/null +++ b/homeassistant/components/holiday/config_flow.py @@ -0,0 +1,99 @@ +"""Config flow for Holiday integration.""" +from __future__ import annotations + +from typing import Any + +from babel import Locale +from holidays import list_supported_countries +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_COUNTRY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + CountrySelector, + CountrySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_PROVINCE, DOMAIN + +SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False) + + +class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Holiday.""" + + VERSION = 1 + + data: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + self.data = user_input + + selected_country = self.data[CONF_COUNTRY] + + if SUPPORTED_COUNTRIES[selected_country]: + return await self.async_step_province() + + self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) + + locale = Locale(self.hass.config.language) + title = locale.territories[selected_country] + return self.async_create_entry(title=title, data=self.data) + + user_schema = vol.Schema( + { + vol.Optional( + CONF_COUNTRY, default=self.hass.config.country + ): CountrySelector( + CountrySelectorConfig( + countries=list(SUPPORTED_COUNTRIES), + ) + ), + } + ) + + return self.async_show_form(step_id="user", data_schema=user_schema) + + async def async_step_province( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the province step.""" + if user_input is not None: + combined_input: dict[str, Any] = {**self.data, **user_input} + + country = combined_input[CONF_COUNTRY] + province = combined_input.get(CONF_PROVINCE) + + self._async_abort_entries_match( + { + CONF_COUNTRY: country, + CONF_PROVINCE: province, + } + ) + + locale = Locale(self.hass.config.language) + province_str = f", {province}" if province else "" + name = f"{locale.territories[country]}{province_str}" + + return self.async_create_entry(title=name, data=combined_input) + + province_schema = vol.Schema( + { + vol.Optional(CONF_PROVINCE): SelectSelector( + SelectSelectorConfig( + options=SUPPORTED_COUNTRIES[self.data[CONF_COUNTRY]], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + + return self.async_show_form(step_id="province", data_schema=province_schema) diff --git a/homeassistant/components/holiday/const.py b/homeassistant/components/holiday/const.py new file mode 100644 index 00000000000..5d2a567a488 --- /dev/null +++ b/homeassistant/components/holiday/const.py @@ -0,0 +1,6 @@ +"""Constants for the Holiday integration.""" +from typing import Final + +DOMAIN: Final = "holiday" + +CONF_PROVINCE: Final = "province" diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json new file mode 100644 index 00000000000..f73577bddee --- /dev/null +++ b/homeassistant/components/holiday/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "holiday", + "name": "Holiday", + "codeowners": ["@jrieger"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/holiday", + "iot_class": "local_polling", + "requirements": ["holidays==0.37", "babel==2.13.1"] +} diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json new file mode 100644 index 00000000000..4762a48c659 --- /dev/null +++ b/homeassistant/components/holiday/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Already configured. Only a single configuration for country/province combination possible." + }, + "step": { + "user": { + "data": { + "country": "Country" + } + }, + "province": { + "data": { + "province": "Province" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e83a2a74405..aa13faaf501 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -201,6 +201,7 @@ FLOWS = { "hisense_aehw4a1", "hive", "hlk_sw16", + "holiday", "home_connect", "home_plus_control", "homeassistant_sky_connect", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 56b0fa4ef9d..636d1030850 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2408,6 +2408,12 @@ "config_flow": true, "iot_class": "local_push" }, + "holiday": { + "name": "Holiday", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "home_connect": { "name": "Home Connect", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 05525d03300..83bc95a940b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1281,6 +1281,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.holiday.*] +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.homeassistant.exposed_entities] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 5f811f514a5..c9b8f66647f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -504,6 +504,9 @@ azure-eventhub==5.11.1 # homeassistant.components.azure_service_bus azure-servicebus==7.10.0 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.baidu baidu-aip==1.6.6 @@ -1013,6 +1016,7 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday holidays==0.37 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 97a41ba9fe4..9ba43ff0dbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -438,6 +438,9 @@ axis==48 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.homekit base36==0.1.1 @@ -800,6 +803,7 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday holidays==0.37 diff --git a/tests/components/holiday/__init__.py b/tests/components/holiday/__init__.py new file mode 100644 index 00000000000..e906586aabc --- /dev/null +++ b/tests/components/holiday/__init__.py @@ -0,0 +1 @@ +"""Tests for the Holiday integration.""" diff --git a/tests/components/holiday/conftest.py b/tests/components/holiday/conftest.py new file mode 100644 index 00000000000..d9b0d1a5788 --- /dev/null +++ b/tests/components/holiday/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Holiday tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.holiday.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py new file mode 100644 index 00000000000..06011fb8e6b --- /dev/null +++ b/tests/components/holiday/test_calendar.py @@ -0,0 +1,229 @@ +"""Tests for calendar platform of Holiday integration.""" +from datetime import datetime, timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, +) +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_holiday_calendar_entity( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test HolidayCalendarEntity functionality.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "US", CONF_PROVINCE: "AK"}, + title="United States, AK", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await async_setup_component(hass, "calendar", {}) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + state = hass.states.get("calendar.united_states_ak") + assert state is not None + assert state.state == "on" + + # Test holidays for the next year + freezer.move_to(datetime(2023, 12, 31, 12, tzinfo=dt_util.UTC)) + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now() + timedelta(days=1), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2024-01-01", + "end": "2024-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + +async def test_default_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default language.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "FR", CONF_PROVINCE: "BL"}, + title="France, BL", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test French calendar with English language + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "France, BL", + } + ] + } + } + + # Test French calendar with French language + hass.config.language = "fr" + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "Jour de l'an", + "location": "France, BL", + } + ] + } + } + + +async def test_no_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test language defaults to English if language not exist.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "AL"}, + title="Albania", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.albania", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.albania": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "Albania", + } + ] + } + } + + +async def test_no_next_event( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if there is no next event.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "DE"}, + title="Germany", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Move time to out of reach + freezer.move_to(datetime(dt_util.now().year + 5, 1, 1, 12, tzinfo=dt_util.UTC)) + async_fire_time_changed(hass) + + state = hass.states.get("calendar.germany") + assert state is not None + assert state.state == "off" + assert state.attributes == {"friendly_name": "Germany"} diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py new file mode 100644 index 00000000000..e99d310762e --- /dev/null +++ b/tests/components/holiday/test_config_flow.py @@ -0,0 +1,128 @@ +"""Test the Holiday config flow.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Germany, BW" + assert result3["data"] == { + "country": "DE", + "province": "BW", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_no_subdivision(hass: HomeAssistant) -> None: + """Test we get the forms correctly without subdivision.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Sweden" + assert result2["data"] == { + "country": "SE", + } + + +async def test_form_translated_title(hass: HomeAssistant) -> None: + """Test the title gets translated.""" + hass.config.language = "de" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "Schweden" + + +async def test_single_combination_country_province(hass: HomeAssistant) -> None: + """Test that configuring more than one instance is rejected.""" + data_de = { + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BW", + } + data_se = { + CONF_COUNTRY: "SE", + } + MockConfigEntry(domain=DOMAIN, data=data_de).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data=data_se).add_to_hass(hass) + + # Test for country without subdivisions + result_se = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_se, + ) + assert result_se["type"] == FlowResultType.ABORT + assert result_se["reason"] == "already_configured" + + # Test for country with subdivisions + result_de_step1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_de, + ) + assert result_de_step1["type"] == FlowResultType.FORM + + result_de_step2 = await hass.config_entries.flow.async_configure( + result_de_step1["flow_id"], + { + CONF_PROVINCE: data_de[CONF_PROVINCE], + }, + ) + assert result_de_step2["type"] == FlowResultType.ABORT + assert result_de_step2["reason"] == "already_configured" diff --git a/tests/components/holiday/test_init.py b/tests/components/holiday/test_init.py new file mode 100644 index 00000000000..a044e390a68 --- /dev/null +++ b/tests/components/holiday/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the Holiday integration.""" + +from homeassistant.components.holiday.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG_DATA = { + "country": "Germany", + "province": "BW", +} + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test removing integration.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + state: ConfigEntryState = entry.state + assert state == ConfigEntryState.NOT_LOADED From c8bb72935d935680ea7a9c5f8bde552f2d8f7dcd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 08:53:17 -1000 Subject: [PATCH 386/643] Bump bluetooth-data-tools to 1.17.0 (#104935) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b4975e61507..bb052a3a042 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.16.0", + "bluetooth-data-tools==1.17.0", "dbus-fast==2.14.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index db9cd9ba72c..7be54ad739f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "aioesphomeapi==19.2.1", - "bluetooth-data-tools==1.16.0", + "bluetooth-data-tools==1.17.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index a90b5a71c2d..398fcb95872 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.16.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.17.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index ca46565b773..440ec427f8d 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.16.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.17.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index d894b18f545..ed7fc975d9e 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.16.0"] + "requirements": ["bluetooth-data-tools==1.17.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7b788c8f78a..06892125940 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.16.0 +bluetooth-data-tools==1.17.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 diff --git a/requirements_all.txt b/requirements_all.txt index c9b8f66647f..94895a49444 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -567,7 +567,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.16.0 +bluetooth-data-tools==1.17.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ba43ff0dbf..e3424447c2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -479,7 +479,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.16.0 +bluetooth-data-tools==1.17.0 # homeassistant.components.bond bond-async==0.2.1 From 28584ad24056df42d8b85daeea63f6d40b45ba49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 10:00:11 -1000 Subject: [PATCH 387/643] Relocate base Bluetooth scanner code into an external library (#104930) --- .../components/bluetooth/__init__.py | 8 +- .../components/bluetooth/base_scanner.py | 380 +----------------- homeassistant/components/bluetooth/const.py | 44 +- .../components/bluetooth/manifest.json | 3 +- homeassistant/components/bluetooth/models.py | 11 - homeassistant/components/bluetooth/scanner.py | 3 +- homeassistant/package_constraints.txt | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../bluetooth/test_advertisement_tracker.py | 2 +- .../components/bluetooth/test_base_scanner.py | 16 +- tests/components/bluetooth/test_init.py | 4 +- tests/components/bluetooth/test_models.py | 1 - tests/components/bluetooth/test_scanner.py | 32 +- 14 files changed, 51 insertions(+), 460 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 5948d4d1358..a0eb263757a 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -21,6 +21,7 @@ from bluetooth_adapters import ( adapter_unique_name, get_adapters, ) +from habluetooth import HaBluetoothConnector from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb @@ -77,12 +78,7 @@ from .const import ( ) from .manager import BluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - HaBluetoothConnector, -) +from .models import BluetoothCallback, BluetoothChange, BluetoothScanningMode from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError from .storage import BluetoothStorage diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index f7696c2e90b..8267a73fd71 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -1,19 +1,14 @@ """Base classes for HA Bluetooth scanners for bluetooth.""" from __future__ import annotations -from abc import abstractmethod -import asyncio -from collections.abc import Callable, Generator -from contextlib import contextmanager +from collections.abc import Callable from dataclasses import dataclass -import logging -from typing import Any, Final, final +from typing import Any from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from bleak_retry_connector import NO_RSSI_VALUE -from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name -from bluetooth_data_tools import monotonic_time_coarse +from bluetooth_adapters import DiscoveredDeviceAdvertisementData +from habluetooth import BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -25,16 +20,6 @@ from homeassistant.core import ( ) from . import models -from .const import ( - CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, -) -from .models import HaBluetoothConnector - -SCANNER_WATCHDOG_INTERVAL_SECONDS: Final = SCANNER_WATCHDOG_INTERVAL.total_seconds() -MONOTONIC_TIME: Final = monotonic_time_coarse -_LOGGER = logging.getLogger(__name__) @dataclass(slots=True) @@ -46,363 +31,6 @@ class BluetoothScannerDevice: advertisement: AdvertisementData -class BaseHaScanner: - """Base class for high availability BLE scanners.""" - - __slots__ = ( - "adapter", - "connectable", - "source", - "connector", - "_connecting", - "name", - "scanning", - "_last_detection", - "_start_time", - "_cancel_watchdog", - "_loop", - ) - - def __init__( - self, - source: str, - adapter: str, - connector: HaBluetoothConnector | None = None, - ) -> None: - """Initialize the scanner.""" - self.connectable = False - self.source = source - self.connector = connector - self._connecting = 0 - self.adapter = adapter - self.name = adapter_human_name(adapter, source) if adapter != source else source - self.scanning = True - self._last_detection = 0.0 - self._start_time = 0.0 - self._cancel_watchdog: asyncio.TimerHandle | None = None - self._loop: asyncio.AbstractEventLoop | None = None - - @hass_callback - def async_setup(self) -> CALLBACK_TYPE: - """Set up the scanner.""" - self._loop = asyncio.get_running_loop() - return self._unsetup - - @hass_callback - def _async_stop_scanner_watchdog(self) -> None: - """Stop the scanner watchdog.""" - if self._cancel_watchdog: - self._cancel_watchdog.cancel() - self._cancel_watchdog = None - - @hass_callback - def _async_setup_scanner_watchdog(self) -> None: - """If something has restarted or updated, we need to restart the scanner.""" - self._start_time = self._last_detection = MONOTONIC_TIME() - if not self._cancel_watchdog: - self._schedule_watchdog() - - def _schedule_watchdog(self) -> None: - """Schedule the watchdog.""" - loop = self._loop - assert loop is not None - self._cancel_watchdog = loop.call_at( - loop.time() + SCANNER_WATCHDOG_INTERVAL_SECONDS, - self._async_call_scanner_watchdog, - ) - - @final - def _async_call_scanner_watchdog(self) -> None: - """Call the scanner watchdog and schedule the next one.""" - self._async_scanner_watchdog() - self._schedule_watchdog() - - @hass_callback - def _async_watchdog_triggered(self) -> bool: - """Check if the watchdog has been triggered.""" - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - _LOGGER.debug( - "%s: Scanner watchdog time_since_last_detection: %s", - self.name, - time_since_last_detection, - ) - return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT - - @hass_callback - def _async_scanner_watchdog(self) -> None: - """Check if the scanner is running. - - Override this method if you need to do something else when the watchdog - is triggered. - """ - if self._async_watchdog_triggered(): - _LOGGER.info( - ( - "%s: Bluetooth scanner has gone quiet for %ss, check logs on the" - " scanner device for more information" - ), - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - self.scanning = False - return - self.scanning = not self._connecting - - @hass_callback - def _unsetup(self) -> None: - """Unset up the scanner.""" - - @contextmanager - def connecting(self) -> Generator[None, None, None]: - """Context manager to track connecting state.""" - self._connecting += 1 - self.scanning = not self._connecting - try: - yield - finally: - self._connecting -= 1 - self.scanning = not self._connecting - - @property - @abstractmethod - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - - @property - @abstractmethod - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and their advertisement data.""" - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - device_adv_datas = self.discovered_devices_and_advertisement_data.values() - return { - "name": self.name, - "start_time": self._start_time, - "source": self.source, - "scanning": self.scanning, - "type": self.__class__.__name__, - "last_detection": self._last_detection, - "monotonic_time": MONOTONIC_TIME(), - "discovered_devices_and_advertisement_data": [ - { - "name": device.name, - "address": device.address, - "rssi": advertisement_data.rssi, - "advertisement_data": advertisement_data, - "details": device.details, - } - for device, advertisement_data in device_adv_datas - ], - } - - -class BaseHaRemoteScanner(BaseHaScanner): - """Base class for a high availability remote BLE scanner.""" - - __slots__ = ( - "_new_info_callback", - "_discovered_device_advertisement_datas", - "_discovered_device_timestamps", - "_details", - "_expire_seconds", - "_cancel_track", - ) - - def __init__( - self, - scanner_id: str, - name: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - connector: HaBluetoothConnector | None, - connectable: bool, - ) -> None: - """Initialize the scanner.""" - super().__init__(scanner_id, name, connector) - self._new_info_callback = new_info_callback - self._discovered_device_advertisement_datas: dict[ - str, tuple[BLEDevice, AdvertisementData] - ] = {} - self._discovered_device_timestamps: dict[str, float] = {} - self.connectable = connectable - self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} - # Scanners only care about connectable devices. The manager - # will handle taking care of availability for non-connectable devices - self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - self._cancel_track: asyncio.TimerHandle | None = None - - def _cancel_expire_devices(self) -> None: - """Cancel the expiration of old devices.""" - if self._cancel_track: - self._cancel_track.cancel() - self._cancel_track = None - - @hass_callback - def _unsetup(self) -> None: - """Unset up the scanner.""" - self._async_stop_scanner_watchdog() - self._cancel_expire_devices() - - @hass_callback - def async_setup(self) -> CALLBACK_TYPE: - """Set up the scanner.""" - super().async_setup() - self._schedule_expire_devices() - self._async_setup_scanner_watchdog() - return self._unsetup - - def _schedule_expire_devices(self) -> None: - """Schedule the expiration of old devices.""" - loop = self._loop - assert loop is not None - self._cancel_expire_devices() - self._cancel_track = loop.call_at(loop.time() + 30, self._async_expire_devices) - - @hass_callback - def _async_expire_devices(self) -> None: - """Expire old devices.""" - now = MONOTONIC_TIME() - expired = [ - address - for address, timestamp in self._discovered_device_timestamps.items() - if now - timestamp > self._expire_seconds - ] - for address in expired: - del self._discovered_device_advertisement_datas[address] - del self._discovered_device_timestamps[address] - self._schedule_expire_devices() - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - device_adv_datas = self._discovered_device_advertisement_datas.values() - return [ - device_advertisement_data[0] - for device_advertisement_data in device_adv_datas - ] - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self._discovered_device_advertisement_datas - - @hass_callback - def _async_on_advertisement( - self, - address: str, - rssi: int, - local_name: str | None, - service_uuids: list[str], - service_data: dict[str, bytes], - manufacturer_data: dict[int, bytes], - tx_power: int | None, - details: dict[Any, Any], - advertisement_monotonic_time: float, - ) -> None: - """Call the registered callback.""" - self.scanning = not self._connecting - self._last_detection = advertisement_monotonic_time - try: - prev_discovery = self._discovered_device_advertisement_datas[address] - except KeyError: - # We expect this is the rare case and since py3.11+ has - # near zero cost try on success, and we can avoid .get() - # which is slower than [] we use the try/except pattern. - device = BLEDevice( - address=address, - name=local_name, - details=self._details | details, - rssi=rssi, # deprecated, will be removed in newer bleak - ) - else: - # Merge the new data with the old data - # to function the same as BlueZ which - # merges the dicts on PropertiesChanged - prev_device = prev_discovery[0] - prev_advertisement = prev_discovery[1] - prev_service_uuids = prev_advertisement.service_uuids - prev_service_data = prev_advertisement.service_data - prev_manufacturer_data = prev_advertisement.manufacturer_data - prev_name = prev_device.name - - if prev_name and (not local_name or len(prev_name) > len(local_name)): - local_name = prev_name - - if service_uuids and service_uuids != prev_service_uuids: - service_uuids = list({*service_uuids, *prev_service_uuids}) - elif not service_uuids: - service_uuids = prev_service_uuids - - if service_data and service_data != prev_service_data: - service_data = prev_service_data | service_data - elif not service_data: - service_data = prev_service_data - - if manufacturer_data and manufacturer_data != prev_manufacturer_data: - manufacturer_data = prev_manufacturer_data | manufacturer_data - elif not manufacturer_data: - manufacturer_data = prev_manufacturer_data - # - # Bleak updates the BLEDevice via create_or_update_device. - # We need to do the same to ensure integrations that already - # have the BLEDevice object get the updated details when they - # change. - # - # https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203 - # - device = prev_device - device.name = local_name - device.details = self._details | details - # pylint: disable-next=protected-access - device._rssi = rssi # deprecated, will be removed in newer bleak - - advertisement_data = AdvertisementData( - local_name=None if local_name == "" else local_name, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, - rssi=rssi, - platform_data=(), - ) - self._discovered_device_advertisement_datas[address] = ( - device, - advertisement_data, - ) - self._discovered_device_timestamps[address] = advertisement_monotonic_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=local_name or address, - address=address, - rssi=rssi, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=self.connectable, - time=advertisement_monotonic_time, - ) - ) - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - now = MONOTONIC_TIME() - return await super().async_diagnostics() | { - "connectable": self.connectable, - "discovered_device_timestamps": self._discovered_device_timestamps, - "time_since_last_device_detection": { - address: now - timestamp - for address, timestamp in self._discovered_device_timestamps.items() - }, - } - - class HomeAssistantRemoteScanner(BaseHaRemoteScanner): """Home Assistant remote BLE scanner. diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index 150239eec02..fa8efabcb1d 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -1,9 +1,15 @@ """Constants for the Bluetooth integration.""" from __future__ import annotations -from datetime import timedelta from typing import Final +from habluetooth import ( # noqa: F401 + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, +) + DOMAIN = "bluetooth" CONF_ADAPTER = "adapter" @@ -19,42 +25,6 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 START_TIMEOUT = 15 -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker cannot determine the interval. -# -# We have to set this quite high as we don't know -# when devices fall out of the ESPHome device (and other non-local scanners)'s -# stack like we do with BlueZ so its safer to assume its available -# since if it does go out of range and it is in range -# of another device the timeout is much shorter and it will -# switch over to using that adapter anyways. -# -FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15 - -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker can determine the interval for -# connectable devices. -# -# BlueZ uses 180 seconds by default but we give it a bit more time -# to account for the esp32's bluetooth stack being a bit slower -# than BlueZ's. -CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195 - - -# We must recover before we hit the 180s mark -# where the device is removed from the stack -# or the devices will go unavailable. Since -# we only check every 30s, we need this number -# to be -# 180s Time when device is removed from stack -# - 30s check interval -# - 30s scanner restart time * 2 -# -SCANNER_WATCHDOG_TIMEOUT: Final = 90 -# How often to check if the scanner has reached -# the SCANNER_WATCHDOG_TIMEOUT without seeing anything -SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30) - # When the linux kernel is configured with # CONFIG_FW_LOADER_USER_HELPER_FALLBACK it diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index bb052a3a042..74cec5edfe2 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,7 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", - "dbus-fast==2.14.0" + "dbus-fast==2.14.0", + "habluetooth==0.1.0" ] } diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 1b8c12c6eb3..48ba021cd6c 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -2,11 +2,9 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Final -from bleak import BaseBleakClient from bluetooth_data_tools import monotonic_time_coarse from home_assistant_bluetooth import BluetoothServiceInfoBleak @@ -19,15 +17,6 @@ MANAGER: BluetoothManager | None = None MONOTONIC_TIME: Final = monotonic_time_coarse -@dataclass(slots=True) -class HaBluetoothConnector: - """Data for how to connect a BLEDevice from a given scanner.""" - - client: type[BaseBleakClient] - source: str - can_connect: Callable[[], bool] - - class BluetoothScanningMode(Enum): """The mode of scanning for bluetooth devices.""" diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 712fe1c0d9a..203bdac68a6 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -16,13 +16,14 @@ from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback from bleak_retry_connector import restore_discoveries from bluetooth_adapters import DEFAULT_ADDRESS +from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME from dbus_fast import InvalidMessageError from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util.package import is_docker_env -from .base_scanner import MONOTONIC_TIME, BaseHaScanner +from .base_scanner import BaseHaScanner from .const import ( SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 06892125940..f7f67dcbf7a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,6 +23,7 @@ dbus-fast==2.14.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 +habluetooth==0.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index 94895a49444..640c050e05f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -983,6 +983,9 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==0.1.0 + # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3424447c2f..608a53b46f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -782,6 +782,9 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==0.1.0 + # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index f04ea2873f0..6ae847ba84a 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -352,7 +352,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c ) switchbot_device_went_unavailable = False - scanner = FakeScanner(hass, "new", "fake_adapter") + scanner = FakeScanner("new", "fake_adapter") cancel_scanner = async_register_scanner(hass, scanner, False) @callback diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index a39f18e037e..5886cc10aac 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -215,7 +215,7 @@ async def test_remote_scanner_expires_connectable( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=expire_monotonic, ): async_fire_time_changed(hass, expire_utc) @@ -298,7 +298,7 @@ async def test_remote_scanner_expires_non_connectable( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=expire_monotonic, ): async_fire_time_changed(hass, expire_utc) @@ -314,7 +314,7 @@ async def test_remote_scanner_expires_non_connectable( seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=expire_monotonic, ): async_fire_time_changed(hass, expire_utc) @@ -515,7 +515,7 @@ async def test_device_with_ten_minute_advertising_interval( ) with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=new_time, ): scanner.inject_advertisement(bparasite_device, bparasite_device_adv) @@ -528,7 +528,7 @@ async def test_device_with_ten_minute_advertising_interval( for _ in range(1, 20): new_time += advertising_interval with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=new_time, ): scanner.inject_advertisement(bparasite_device, bparasite_device_adv) @@ -562,7 +562,7 @@ async def test_device_with_ten_minute_advertising_interval( "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", return_value=missed_advertisement_future_time, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=missed_advertisement_future_time, ): # Fire once for the scanner to expire the device @@ -629,7 +629,7 @@ async def test_scanner_stops_responding( ) # We hit the timer with no detections, so we reset the adapter and restart the scanner with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=failure_reached_time, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -653,7 +653,7 @@ async def test_scanner_stops_responding( failure_reached_time += 1 with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=failure_reached_time, ): scanner.inject_advertisement(bparasite_device, bparasite_device_adv) diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 21fade843f5..b24bb97e1e3 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -2815,7 +2815,7 @@ async def test_scanner_count_connectable( hass: HomeAssistant, enable_bluetooth: None ) -> None: """Test getting the connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") + scanner = FakeScanner("any", "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=True) == 1 cancel() @@ -2823,7 +2823,7 @@ async def test_scanner_count_connectable( async def test_scanner_count(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test getting the connectable and non-connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") + scanner = FakeScanner("any", "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=False) == 2 cancel() diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 1d07ab75a48..8cffbe685b6 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -107,7 +107,6 @@ async def test_wrapped_bleak_client_local_adapter_only( return None scanner = FakeScanner( - hass, "00:00:00:00:00:01", "hci0", ) diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index bc32a5b302d..b660be74aa9 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -228,7 +228,7 @@ async def test_recovery_from_dbus_restart( # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -238,7 +238,7 @@ async def test_recovery_from_dbus_restart( # Fire a callback to reset the timer with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ): _callback( @@ -248,7 +248,7 @@ async def test_recovery_from_dbus_restart( # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -258,7 +258,7 @@ async def test_recovery_from_dbus_restart( # We hit the timer, so we restart the scanner with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, ): async_fire_time_changed( @@ -303,7 +303,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", @@ -318,7 +318,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -328,7 +328,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -338,7 +338,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: # We hit the timer with no detections, so we reset the adapter and restart the scanner with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), @@ -392,7 +392,7 @@ async def test_adapter_scanner_fails_to_start_first_time( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", @@ -407,7 +407,7 @@ async def test_adapter_scanner_fails_to_start_first_time( # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -417,7 +417,7 @@ async def test_adapter_scanner_fails_to_start_first_time( # Ensure we don't restart the scanner if we don't need to with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) @@ -427,7 +427,7 @@ async def test_adapter_scanner_fails_to_start_first_time( # We hit the timer with no detections, so we reset the adapter and restart the scanner with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), @@ -443,7 +443,7 @@ async def test_adapter_scanner_fails_to_start_first_time( # We hit the timer again the previous start call failed, make sure # we try again with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), @@ -506,7 +506,7 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", @@ -557,7 +557,7 @@ async def test_restart_takes_longer_than_watchdog_time( "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", @@ -572,7 +572,7 @@ async def test_restart_takes_longer_than_watchdog_time( # Now force a recover adapter 2x for _ in range(2): with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), From ecc49e61f658ad2a1ed071d3606a1d9a5203130c Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sun, 3 Dec 2023 14:05:30 -0600 Subject: [PATCH 388/643] Bump plexapi to 4.15.6 (#104949) --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index a11d2d865c2..6dbd6118d7c 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.4", + "PlexAPI==4.15.6", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/requirements_all.txt b/requirements_all.txt index 640c050e05f..c790b593a45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ Mastodon.py==1.5.1 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.6 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 608a53b46f6..dd48bada87c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,7 +39,7 @@ HATasmota==0.7.3 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.6 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 From 833805f9be329172bf2844a2cf3babbee4caa09a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 3 Dec 2023 21:10:37 +0100 Subject: [PATCH 389/643] Add StreamLabsWater to strict typing (#104957) --- .strict-typing | 1 + .../streamlabswater/binary_sensor.py | 16 +++++++++----- .../components/streamlabswater/sensor.py | 22 +++++++++++-------- mypy.ini | 10 +++++++++ 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.strict-typing b/.strict-typing index daf2baabf67..6180379f977 100644 --- a/.strict-typing +++ b/.strict-typing @@ -317,6 +317,7 @@ homeassistant.components.statistics.* homeassistant.components.steamist.* homeassistant.components.stookalert.* homeassistant.components.stream.* +homeassistant.components.streamlabswater.* homeassistant.components.sun.* homeassistant.components.surepetcare.* homeassistant.components.switch.* diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index 43465fb99ae..4a974077592 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +from streamlabswater.streamlabswater import StreamlabsClient + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,19 +41,19 @@ def setup_platform( class StreamlabsLocationData: """Track and query location data.""" - def __init__(self, location_id, client): + def __init__(self, location_id: str, client: StreamlabsClient) -> None: """Initialize the location data.""" self._location_id = location_id self._client = client self._is_away = None @Throttle(MIN_TIME_BETWEEN_LOCATION_UPDATES) - def update(self): + def update(self) -> None: """Query and store location data.""" location = self._client.get_location(self._location_id) self._is_away = location["homeAway"] == "away" - def is_away(self): + def is_away(self) -> bool | None: """Return whether away more is enabled.""" return self._is_away @@ -59,19 +61,21 @@ class StreamlabsLocationData: class StreamlabsAwayMode(BinarySensorEntity): """Monitor the away mode state.""" - def __init__(self, location_name, streamlabs_location_data): + def __init__( + self, location_name: str, streamlabs_location_data: StreamlabsLocationData + ) -> None: """Initialize the away mode device.""" self._location_name = location_name self._streamlabs_location_data = streamlabs_location_data self._is_away = None @property - def name(self): + def name(self) -> str: """Return the name for away mode.""" return f"{self._location_name} {NAME_AWAY_MODE}" @property - def is_on(self): + def is_on(self) -> bool | None: """Return if away mode is on.""" return self._streamlabs_location_data.is_away() diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 42cf2bb588f..42e551c5c11 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +from streamlabswater.streamlabswater import StreamlabsClient + from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant @@ -48,7 +50,7 @@ def setup_platform( class StreamlabsUsageData: """Track and query usage data.""" - def __init__(self, location_id, client): + def __init__(self, location_id: str, client: StreamlabsClient) -> None: """Initialize the usage data.""" self._location_id = location_id self._client = client @@ -57,22 +59,22 @@ class StreamlabsUsageData: self._this_year = None @Throttle(MIN_TIME_BETWEEN_USAGE_UPDATES) - def update(self): + def update(self) -> None: """Query and store usage data.""" water_usage = self._client.get_water_usage_summary(self._location_id) self._today = round(water_usage["today"], 1) self._this_month = round(water_usage["thisMonth"], 1) self._this_year = round(water_usage["thisYear"], 1) - def get_daily_usage(self): + def get_daily_usage(self) -> float | None: """Return the day's usage.""" return self._today - def get_monthly_usage(self): + def get_monthly_usage(self) -> float | None: """Return the month's usage.""" return self._this_month - def get_yearly_usage(self): + def get_yearly_usage(self) -> float | None: """Return the year's usage.""" return self._this_year @@ -83,7 +85,9 @@ class StreamLabsDailyUsage(SensorEntity): _attr_device_class = SensorDeviceClass.WATER _attr_native_unit_of_measurement = UnitOfVolume.GALLONS - def __init__(self, location_name, streamlabs_usage_data): + def __init__( + self, location_name: str, streamlabs_usage_data: StreamlabsUsageData + ) -> None: """Initialize the daily water usage device.""" self._location_name = location_name self._streamlabs_usage_data = streamlabs_usage_data @@ -95,7 +99,7 @@ class StreamLabsDailyUsage(SensorEntity): return f"{self._location_name} {NAME_DAILY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current daily usage.""" return self._streamlabs_usage_data.get_daily_usage() @@ -113,7 +117,7 @@ class StreamLabsMonthlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_MONTHLY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current monthly usage.""" return self._streamlabs_usage_data.get_monthly_usage() @@ -127,6 +131,6 @@ class StreamLabsYearlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_YEARLY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current yearly usage.""" return self._streamlabs_usage_data.get_yearly_usage() diff --git a/mypy.ini b/mypy.ini index 83bc95a940b..2a3a5f0fb0f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2932,6 +2932,16 @@ warn_return_any = true warn_unreachable = true no_implicit_reexport = true +[mypy-homeassistant.components.streamlabswater.*] +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.sun.*] check_untyped_defs = true disallow_incomplete_defs = true From 23cd66c54b0f55e0aba29cd8e3dd39666500cf82 Mon Sep 17 00:00:00 2001 From: Alex Thompson Date: Sun, 3 Dec 2023 16:13:26 -0500 Subject: [PATCH 390/643] Fix Lyric HVAC mode reset on temperature change (#104910) * Fix Lyric HVAC mode reset on temperature change * Reduce code duplication * Revert additional bugfix Co-authored-by: Jan Bouwhuis --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/lyric/climate.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index d0bad55ff14..f01e4c4fe55 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -324,6 +324,15 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): "Could not find target_temp_low and/or target_temp_high in" " arguments" ) + + # If the device supports "Auto" mode, don't pass the mode when setting the + # temperature + mode = ( + None + if device.changeableValues.mode == LYRIC_HVAC_MODE_HEAT_COOL + else HVAC_MODES[device.changeableValues.heatCoolMode] + ) + _LOGGER.debug("Set temperature: %s - %s", target_temp_low, target_temp_high) try: await self._update_thermostat( @@ -331,7 +340,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): device, coolSetpoint=target_temp_high, heatSetpoint=target_temp_low, - mode=HVAC_MODES[device.changeableValues.heatCoolMode], + mode=mode, ) except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) From c1f68c37673525ce0d6f8c1e240cb84601d17303 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 12:18:10 -1000 Subject: [PATCH 391/643] Bump habluetooth to 0.4.0 (#104958) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/bluetooth/scanner.py | 1 + homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 74cec5edfe2..a295e3f70ad 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.14.0", - "habluetooth==0.1.0" + "habluetooth==0.4.0" ] } diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 203bdac68a6..95733039df4 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -140,6 +140,7 @@ class HaScanner(BaseHaScanner): self._new_info_callback = new_info_callback self.scanning = False self.hass = hass + self._last_detection = 0.0 @property def discovered_devices(self) -> list[BLEDevice]: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f7f67dcbf7a..ad55e397b72 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.14.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.1.0 +habluetooth==0.4.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index c790b593a45..38f8f3077eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.1.0 +habluetooth==0.4.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd48bada87c..077a07258b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.1.0 +habluetooth==0.4.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 From fe2906f15965d32dbfc65555b35d35633a20634c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 02:06:01 +0100 Subject: [PATCH 392/643] Do not allow smtp to access insecure files (#104972) --- homeassistant/components/smtp/notify.py | 30 ++++++++++++++++------- tests/components/smtp/test_notify.py | 32 ++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 6b960409305..02a5a6408b6 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -8,6 +8,7 @@ from email.mime.text import MIMEText import email.utils import logging import os +from pathlib import Path import smtplib import voluptuous as vol @@ -193,10 +194,15 @@ class MailNotificationService(BaseNotificationService): if data := kwargs.get(ATTR_DATA): if ATTR_HTML in data: msg = _build_html_msg( - message, data[ATTR_HTML], images=data.get(ATTR_IMAGES, []) + self.hass, + message, + data[ATTR_HTML], + images=data.get(ATTR_IMAGES, []), ) else: - msg = _build_multipart_msg(message, images=data.get(ATTR_IMAGES, [])) + msg = _build_multipart_msg( + self.hass, message, images=data.get(ATTR_IMAGES, []) + ) else: msg = _build_text_msg(message) @@ -241,13 +247,21 @@ def _build_text_msg(message): return MIMEText(message) -def _attach_file(atch_name, content_id=""): +def _attach_file(hass, atch_name, content_id=""): """Create a message attachment. If MIMEImage is successful and content_id is passed (HTML), add images in-line. Otherwise add them as attachments. """ try: + file_path = Path(atch_name).parent + if not hass.config.is_allowed_path(str(file_path)): + _LOGGER.warning( + "'%s' is not secure to load data from, ignoring attachment '%s'!", + file_path, + atch_name, + ) + return with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() except FileNotFoundError: @@ -277,22 +291,22 @@ def _attach_file(atch_name, content_id=""): return attachment -def _build_multipart_msg(message, images): +def _build_multipart_msg(hass, message, images): """Build Multipart message with images as attachments.""" - _LOGGER.debug("Building multipart email with image attachment(s)") + _LOGGER.debug("Building multipart email with image attachme_build_html_msgnt(s)") msg = MIMEMultipart() body_txt = MIMEText(message) msg.attach(body_txt) for atch_name in images: - attachment = _attach_file(atch_name) + attachment = _attach_file(hass, atch_name) if attachment: msg.attach(attachment) return msg -def _build_html_msg(text, html, images): +def _build_html_msg(hass, text, html, images): """Build Multipart message with in-line images and rich HTML (UTF-8).""" _LOGGER.debug("Building HTML rich email") msg = MIMEMultipart("related") @@ -303,7 +317,7 @@ def _build_html_msg(text, html, images): for atch_name in images: name = os.path.basename(atch_name) - attachment = _attach_file(atch_name, name) + attachment = _attach_file(hass, atch_name, name) if attachment: msg.attach(attachment) return msg diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index bca5a5674df..06110a3e5dc 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -1,4 +1,5 @@ """The tests for the notify smtp platform.""" +from pathlib import Path import re from unittest.mock import patch @@ -132,15 +133,44 @@ EMAIL_DATA = [ ], ) def test_send_message( - message_data, data, content_type, hass: HomeAssistant, message + hass: HomeAssistant, message_data, data, content_type, message ) -> None: """Verify if we can send messages of all types correctly.""" sample_email = "" + message.hass = hass + hass.config.allowlist_external_dirs.add(Path("tests/testing_config").resolve()) with patch("email.utils.make_msgid", return_value=sample_email): result, _ = message.send_message(message_data, data=data) assert content_type in result +@pytest.mark.parametrize( + ("message_data", "data", "content_type"), + [ + ( + "Test msg", + {"images": ["tests/testing_config/notify/test.jpg"]}, + "Content-Type: multipart/mixed", + ), + ], +) +def test_sending_insecure_files_fails( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + message_data, + data, + content_type, + message, +) -> None: + """Verify if we cannot send messages with insecure attachments.""" + sample_email = "" + message.hass = hass + with patch("email.utils.make_msgid", return_value=sample_email): + result, _ = message.send_message(message_data, data=data) + assert content_type in result + assert "test.jpg' is not secure to load data from, ignoring attachment" + + def test_send_text_message(hass: HomeAssistant, message) -> None: """Verify if we can send simple text message.""" expected = ( From b56cd169ac9548be2d741288626d469723a23a25 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Dec 2023 07:46:36 +0100 Subject: [PATCH 393/643] Use constants in config flow scaffold (#104964) --- .../config_flow/integration/config_flow.py | 13 ++--- .../config_flow/tests/test_config_flow.py | 49 +++++++++---------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 3dd60b51296..caef6c2e729 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -7,6 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -18,9 +19,9 @@ _LOGGER = logging.getLogger(__name__) # TODO adjust the data schema to the data that you need STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required("host"): str, - vol.Required("username"): str, - vol.Required("password"): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -50,12 +51,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, # If your PyPI package is not built with async, pass your methods # to the executor: # await hass.async_add_executor_job( - # your_validate_func, data["username"], data["password"] + # your_validate_func, data[CONF_USERNAME], data[CONF_PASSWORD] # ) - hub = PlaceholderHub(data["host"]) + hub = PlaceholderHub(data[CONF_HOST]) - if not await hub.authenticate(data["username"], data["password"]): + if not await hub.authenticate(data[CONF_USERNAME], data[CONF_PASSWORD]): raise InvalidAuth # If you cannot connect: diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index cbc1449378c..6ef59bf4337 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -1,16 +1,13 @@ """Test the NEW_NAME config flow.""" from unittest.mock import AsyncMock, patch -import pytest - from homeassistant import config_entries from homeassistant.components.NEW_DOMAIN.config_flow import CannotConnect, InvalidAuth from homeassistant.components.NEW_DOMAIN.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" @@ -24,22 +21,22 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Name of the device" - assert result2["data"] == { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", } assert len(mock_setup_entry.mock_calls) == 1 @@ -54,17 +51,17 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=InvalidAuth, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -77,14 +74,14 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=CannotConnect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} From 374b1cfd0c60ca83b222555e99795ab917fcc35e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Dec 2023 07:48:05 +0100 Subject: [PATCH 394/643] Fix bug in config flow scaffold (#104965) --- script/scaffold/templates/config_flow/tests/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 6ef59bf4337..f08f95e74fc 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -15,7 +15,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["errors"] is None + assert result["errors"] == {} with patch( "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", From 453f91a3ae8e270e6a1024ede61e7a2fc7eb102c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 4 Dec 2023 17:09:15 +1000 Subject: [PATCH 395/643] Add virtual integration Fujitsu anywAIR (#102978) Add anywair --- homeassistant/components/fujitsu_anywair/__init__.py | 1 + homeassistant/components/fujitsu_anywair/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/fujitsu_anywair/__init__.py create mode 100644 homeassistant/components/fujitsu_anywair/manifest.json diff --git a/homeassistant/components/fujitsu_anywair/__init__.py b/homeassistant/components/fujitsu_anywair/__init__.py new file mode 100644 index 00000000000..5845e00f8b0 --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/__init__.py @@ -0,0 +1 @@ +"""Fujitsu anywAIR virtual integration for Home Assistant.""" diff --git a/homeassistant/components/fujitsu_anywair/manifest.json b/homeassistant/components/fujitsu_anywair/manifest.json new file mode 100644 index 00000000000..463f0724919 --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "fujitsu_anywair", + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 636d1030850..d99487c2a60 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1941,6 +1941,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "fujitsu_anywair": { + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" + }, "fully_kiosk": { "name": "Fully Kiosk Browser", "integration_type": "hub", From 401c8903646f9b1e00ba32b40c6a2cec8b735b1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 21:42:07 -1000 Subject: [PATCH 396/643] Bump habluetooth to 0.5.1 (#104969) * bump lib * bump again to be patchable --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index a295e3f70ad..71850ca1320 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.14.0", - "habluetooth==0.4.0" + "habluetooth==0.5.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ad55e397b72..f7cc9199974 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.14.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.4.0 +habluetooth==0.5.1 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index 38f8f3077eb..f43f0889e05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.4.0 +habluetooth==0.5.1 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 077a07258b0..e04cb291a59 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.4.0 +habluetooth==0.5.1 # homeassistant.components.cloud hass-nabucasa==0.74.0 From 6fd96f856d6c316192297026de20d452d24788ee Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 4 Dec 2023 07:48:47 +0000 Subject: [PATCH 397/643] Bump evohome-async to 0.4.13 (#104960) bump client to 0.4.13 --- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 769c8e597cd..e8b54eac38e 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.9"] + "requirements": ["evohome-async==0.4.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index f43f0889e05..4ad1157c547 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -792,7 +792,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.9 +evohome-async==0.4.13 # homeassistant.components.faa_delays faadelays==2023.9.1 From 3b5e498c30c24c485c37c980694e7b9e619aa63a Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Mon, 4 Dec 2023 00:07:46 -0800 Subject: [PATCH 398/643] Bump screenlogicpy to v0.10.0 (#104866) --- .../components/screenlogic/climate.py | 37 +++++++++++-------- .../components/screenlogic/coordinator.py | 13 +++++-- homeassistant/components/screenlogic/data.py | 5 ++- .../components/screenlogic/entity.py | 14 +++++-- .../components/screenlogic/manifest.json | 2 +- .../components/screenlogic/number.py | 35 +++++------------- .../components/screenlogic/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 58 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 1d3f366a498..d78c2c16e48 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import logging from typing import Any -from screenlogicpy.const.common import UNIT +from screenlogicpy.const.common import UNIT, ScreenLogicCommunicationError from screenlogicpy.const.data import ATTR, DEVICE, VALUE from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.heat import HEAT_MODE @@ -150,13 +150,16 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - if not await self.gateway.async_set_heat_temp( - int(self._data_key), int(temperature) - ): + try: + await self.gateway.async_set_heat_temp( + int(self._data_key), int(temperature) + ) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_temperature {temperature} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set temperature for body %s to %s", self._data_key, temperature) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -166,13 +169,14 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): else: mode = HEAT_MODE.parse(self.preset_mode) - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_hvac_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode.name) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -183,13 +187,14 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): if self.hvac_mode == HVACMode.OFF: return - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_preset_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode.name) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index 74f49927171..f16f2b9ff34 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -2,8 +2,13 @@ from datetime import timedelta import logging -from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.common import ( + SL_GATEWAY_IP, + SL_GATEWAY_NAME, + SL_GATEWAY_PORT, + ScreenLogicCommunicationError, +) from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.config_entries import ConfigEntry @@ -91,7 +96,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): await self.gateway.async_connect(**connect_info) await self._async_update_configured_data() - except ScreenLogicError as ex: + except ScreenLogicCommunicationError as sle: if self.gateway.is_connected: await self.gateway.async_disconnect() - raise UpdateFailed(ex.msg) from ex + raise UpdateFailed(sle.msg) from sle diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py index 719cebc1ef6..cda1bc83f81 100644 --- a/homeassistant/components/screenlogic/data.py +++ b/homeassistant/components/screenlogic/data.py @@ -8,7 +8,10 @@ ENTITY_MIGRATIONS = { "new_name": "Active Alert", }, "chem_calcium_harness": { - "new_key": VALUE.CALCIUM_HARNESS, + "new_key": VALUE.CALCIUM_HARDNESS, + }, + "calcium_harness": { + "new_key": VALUE.CALCIUM_HARDNESS, }, "chem_current_orp": { "new_key": VALUE.ORP_NOW, diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 3b45aa699d3..253d16610e4 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -6,7 +6,11 @@ import logging from typing import Any from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const.common import ON_OFF +from screenlogicpy.const.common import ( + ON_OFF, + ScreenLogicCommunicationError, + ScreenLogicError, +) from screenlogicpy.const.data import ATTR from screenlogicpy.const.msg import CODE @@ -170,8 +174,10 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): await self._async_set_circuit(ON_OFF.OFF) async def _async_set_circuit(self, state: ON_OFF) -> None: - if not await self.gateway.async_set_circuit(self._data_key, state.value): + try: + await self.gateway.async_set_circuit(self._data_key, state.value) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: raise HomeAssistantError( - f"Failed to set_circuit {self._data_key} {state.value}" - ) + f"Failed to set_circuit {self._data_key} {state.value}: {sle.msg}" + ) from sle _LOGGER.debug("Set circuit %s %s", self._data_key, state.value) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 69bed1af700..434b8921bc2 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.4"] + "requirements": ["screenlogicpy==0.10.0"] } diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index a52e894c72b..091d377a56b 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging +from screenlogicpy.const.common import ScreenLogicCommunicationError, ScreenLogicError from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from screenlogicpy.device_const.system import EQUIPMENT_FLAG @@ -15,6 +16,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as SL_DOMAIN @@ -32,7 +34,6 @@ class ScreenLogicNumberRequiredMixin: """Describes a required mixin for a ScreenLogic number entity.""" set_value_name: str - set_value_args: tuple[tuple[str | int, ...], ...] @dataclass @@ -47,20 +48,12 @@ class ScreenLogicNumberDescription( SUPPORTED_SCG_NUMBERS = [ ScreenLogicNumberDescription( set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.POOL_SETPOINT, entity_category=EntityCategory.CONFIG, ), ScreenLogicNumberDescription( set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.SPA_SETPOINT, entity_category=EntityCategory.CONFIG, @@ -113,7 +106,6 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): f"set_value_name '{entity_description.set_value_name}' is not a coroutine" ) self._set_value_func: Callable[..., Awaitable[bool]] = func - self._set_value_args = entity_description.set_value_args self._attr_native_unit_of_measurement = get_ha_unit( self.entity_data.get(ATTR.UNIT) ) @@ -138,21 +130,14 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - # Current API requires certain values to be set at the same time. This - # gathers the existing values and updates the particular value being - # set by this entity. - args = {} - for data_path in self._set_value_args: - data_key = data_path[-1] - args[data_key] = self.coordinator.gateway.get_value(*data_path, strict=True) - # Current API requires int values for the currently supported numbers. value = int(value) - args[self._data_key] = value - - if await self._set_value_func(*args.values()): - _LOGGER.debug("Set '%s' to %s", self._data_key, value) - await self._async_refresh() - else: - _LOGGER.debug("Failed to set '%s' to %s", self._data_key, value) + try: + await self._set_value_func(**{self._data_key: value}) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: + raise HomeAssistantError( + f"Failed to set '{self._data_key}' to {value}: {sle.msg}" + ) from sle + _LOGGER.debug("Set '%s' to %s", self._data_key, value) + await self._async_refresh() diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index bbcf8458014..5d4efc55883 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -139,7 +139,7 @@ SUPPORTED_INTELLICHEM_SENSORS = [ ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), - key=VALUE.CALCIUM_HARNESS, + key=VALUE.CALCIUM_HARDNESS, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, diff --git a/requirements_all.txt b/requirements_all.txt index 4ad1157c547..4798e238115 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2411,7 +2411,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e04cb291a59..f76644fbbd6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1799,7 +1799,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.backup securetar==2023.3.0 From 67039e0f2618031c2cd09c0233ef3fb5b2e72eb9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 22:10:13 -1000 Subject: [PATCH 399/643] Remove monotonic_time_coarse datetime helper (#104892) --- homeassistant/util/dt.py | 29 ----------------------------- tests/util/test_dt.py | 6 ------ 2 files changed, 35 deletions(-) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 34a81728d14..4859c5c85dd 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -5,9 +5,7 @@ import bisect from contextlib import suppress import datetime as dt from functools import partial -import platform import re -import time from typing import Any import zoneinfo @@ -16,7 +14,6 @@ import ciso8601 DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.UTC DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC -CLOCK_MONOTONIC_COARSE = 6 # EPOCHORDINAL is not exposed as a constant # https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L12 @@ -476,29 +473,3 @@ def _datetime_ambiguous(dattim: dt.datetime) -> bool: assert dattim.tzinfo is not None opposite_fold = dattim.replace(fold=not dattim.fold) return _datetime_exists(dattim) and dattim.utcoffset() != opposite_fold.utcoffset() - - -def __gen_monotonic_time_coarse() -> partial[float]: - """Return a function that provides monotonic time in seconds. - - This is the coarse version of time_monotonic, which is faster but less accurate. - - Since many arm64 and 32-bit platforms don't support VDSO with time.monotonic - because of errata, we can't rely on the kernel to provide a fast - monotonic time. - - https://lore.kernel.org/lkml/20170404171826.25030-1-marc.zyngier@arm.com/ - """ - # We use a partial here since its implementation is in native code - # which allows us to avoid the overhead of the global lookup - # of CLOCK_MONOTONIC_COARSE. - return partial(time.clock_gettime, CLOCK_MONOTONIC_COARSE) - - -monotonic_time_coarse = time.monotonic -with suppress(Exception): - if ( - platform.system() == "Linux" - and abs(time.monotonic() - __gen_monotonic_time_coarse()()) < 1 - ): - monotonic_time_coarse = __gen_monotonic_time_coarse() diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 28695a94400..a973135d831 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta -import time import pytest @@ -737,8 +736,3 @@ def test_find_next_time_expression_tenth_second_pattern_does_not_drift_entering_ assert (next_target - prev_target).total_seconds() == 60 assert next_target.second == 10 prev_target = next_target - - -def test_monotonic_time_coarse() -> None: - """Test monotonic time coarse.""" - assert abs(time.monotonic() - dt_util.monotonic_time_coarse()) < 1 From 6335c245686f56748bb684a77b5ebeb63ed45b7a Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:13:27 +0100 Subject: [PATCH 400/643] Bump bimmer-connected to 0.14.6 (#104961) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 1ebf52e52ae..854a2f87410 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.14.5"] + "requirements": ["bimmer-connected[china]==0.14.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4798e238115..5430b12f3d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -529,7 +529,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.5 +bimmer-connected[china]==0.14.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f76644fbbd6..a293bfb9f91 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -451,7 +451,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.5 +bimmer-connected[china]==0.14.6 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 From 7ae6343b250e1bdb12feca5d4e68f90193a37b0f Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:13:48 +0100 Subject: [PATCH 401/643] Obihai to OpenGarage: add host field description (#104858) Co-authored-by: Jan Bouwhuis --- homeassistant/components/obihai/strings.json | 6 ++++++ homeassistant/components/octoprint/strings.json | 3 +++ homeassistant/components/onewire/strings.json | 3 +++ homeassistant/components/onvif/strings.json | 3 +++ homeassistant/components/opengarage/strings.json | 3 +++ 5 files changed, 18 insertions(+) diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 823bc2e1b8d..f21b4b3706d 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your Obihai device." } }, "dhcp_confirm": { @@ -14,6 +17,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "[%key:component::obihai::config::step::user::data_description::host%]" } } }, diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index c6dbfe6f9c4..63d9753ee1d 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -10,6 +10,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your printer." } }, "reauth_confirm": { diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9e4120b68b2..753f244cfe9 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -12,6 +12,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your 1-Wire device." + }, "title": "Set server details" } } diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index cabab347264..5a36b89688a 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -36,6 +36,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, + "data_description": { + "host": "The hostname or IP address of your ONVIF device." + }, "title": "Configure ONVIF device" }, "configure_profile": { diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json index ba4521d4dcf..f19b458cd0f 100644 --- a/homeassistant/components/opengarage/strings.json +++ b/homeassistant/components/opengarage/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your OpenGarage device." } } }, From 91463566c06d2655ec6d87094c970bfbe5a5a9ab Mon Sep 17 00:00:00 2001 From: Jirka Date: Mon, 4 Dec 2023 09:14:24 +0100 Subject: [PATCH 402/643] Update balboa strings.json (#104977) --- homeassistant/components/balboa/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 101436c0f31..e0af12514da 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -7,7 +7,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address of your Balboa Spa Wifi Device. For example, 192.168.1.58." + "host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58." } } }, From 9c9d8669ec5d92d6549b99bc41a4d513e9f295c3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 09:36:41 +0100 Subject: [PATCH 403/643] Link second Hue host field description (#104885) --- homeassistant/components/hue/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 122cb489d26..114f501d7a3 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -16,7 +16,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of your Hue bridge." + "host": "[%key:component::hue::config::step::init::data_description::host%]" } }, "link": { From 8e42105b2dd8c17c9071ba6b3ca13190d35679dd Mon Sep 17 00:00:00 2001 From: Patrick Decat Date: Mon, 4 Dec 2023 09:45:59 +0100 Subject: [PATCH 404/643] Fix incompatible 'measurement' state and 'volume' device class warnings in Overkiz (#104896) --- homeassistant/components/overkiz/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 41c2f4d1a92..0bb9043c040 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -100,7 +100,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Water volume estimation at 40 °C", icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.VOLUME_STORAGE, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), @@ -110,7 +110,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.IO_OUTLET_ENGINE, From 557e9337bccba5b8f7f49e3365205023842b09b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20H=C3=A4rtel?= <60009336+Haerteleric@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:48:44 +0100 Subject: [PATCH 405/643] Add CB3 descriptor to ZHA manifest (#104071) --- homeassistant/components/zha/manifest.json | 6 ++++++ homeassistant/generated/usb.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index cd53772777a..4c8a58a12cf 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -76,6 +76,12 @@ "description": "*conbee*", "known_devices": ["Conbee II"] }, + { + "vid": "0403", + "pid": "6015", + "description": "*conbee*", + "known_devices": ["Conbee III"] + }, { "vid": "10C4", "pid": "8A2A", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index f58936caf8d..2fdd032c2dd 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -81,6 +81,12 @@ USB = [ "pid": "0030", "vid": "1CF1", }, + { + "description": "*conbee*", + "domain": "zha", + "pid": "6015", + "vid": "0403", + }, { "description": "*zigbee*", "domain": "zha", From b0d0f15911ec4a73bd65d023f3b5eeab6fa24ae6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Dec 2023 22:53:47 -1000 Subject: [PATCH 406/643] Bump dbus-fast to 2.20.0 (#104978) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 71850ca1320..65d8b9cb892 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", - "dbus-fast==2.14.0", + "dbus-fast==2.20.0", "habluetooth==0.5.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f7cc9199974..0ba9076f407 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ bluetooth-data-tools==1.17.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 -dbus-fast==2.14.0 +dbus-fast==2.20.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5430b12f3d5..b005cdcc902 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -658,7 +658,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.20.0 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a293bfb9f91..9911bdba599 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -539,7 +539,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.20.0 # homeassistant.components.debugpy debugpy==1.8.0 From 9b9d9c6116aba9f232707ad0916b1dbd07921cb4 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:37:02 +0100 Subject: [PATCH 407/643] Reolink to Ruckus: add host field description (#104861) Co-authored-by: starkillerOG --- homeassistant/components/reolink/strings.json | 3 +++ homeassistant/components/rfxtrx/strings.json | 3 +++ homeassistant/components/roomba/strings.json | 6 ++++++ homeassistant/components/ruckus_unleashed/strings.json | 3 +++ 4 files changed, 15 insertions(+) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5b26d70b657..5a27f0e38cb 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -10,6 +10,9 @@ "use_https": "Enable HTTPS", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'." } }, "reauth_confirm": { diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 85ddf559cf5..9b99553d3f0 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -19,6 +19,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your RFXCOM RFXtrx device." + }, "title": "Select connection address" }, "setup_serial": { diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index f1816d58613..654c1b7fdfc 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -7,6 +7,9 @@ "description": "Select a Roomba or Braava.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "manual": { @@ -14,6 +17,9 @@ "description": "No Roomba or Braava have been discovered on your network.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "link": { diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json index 769cde67d7a..65a39e5e218 100644 --- a/homeassistant/components/ruckus_unleashed/strings.json +++ b/homeassistant/components/ruckus_unleashed/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ruckus access point." } } }, From ab4c6cddf22cd8a375a5768a29b6025d7d0aa3b2 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:37:51 +0100 Subject: [PATCH 408/643] Radio Thermostat to Renson: add host field description (#104860) --- homeassistant/components/radiotherm/strings.json | 3 +++ homeassistant/components/rainbird/strings.json | 3 +++ homeassistant/components/rainforest_eagle/strings.json | 3 +++ homeassistant/components/renson/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index 693811f59ab..e76bd2d3f2d 100644 --- a/homeassistant/components/radiotherm/strings.json +++ b/homeassistant/components/radiotherm/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Radio Thermostat." } }, "confirm": { diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 6046189ddc4..ea0d64f6208 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Rain Bird device." } } }, diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index 58c7f6bd795..7b5054bfb0f 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "cloud_id": "Cloud ID", "install_code": "Installation Code" + }, + "data_description": { + "host": "The hostname or IP address of your Rainforest gateway." } } }, diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index d6d03ed1c44..8aa7c6244ea 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Renson Endura delta device." } } }, From bf63674af27d255a93eb25e6c9d9141910c5fd64 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:38:52 +0100 Subject: [PATCH 409/643] Nanoleaf to Nut: add host field description (#104857) Co-authored-by: starkillerOG --- homeassistant/components/nanoleaf/strings.json | 3 +++ homeassistant/components/netgear/strings.json | 3 +++ homeassistant/components/nfandroidtv/strings.json | 3 +++ homeassistant/components/nuki/strings.json | 3 +++ homeassistant/components/nut/strings.json | 5 ++++- 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json index 80eb2ded7d0..13e7c9a11a3 100644 --- a/homeassistant/components/nanoleaf/strings.json +++ b/homeassistant/components/nanoleaf/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Nanoleaf device." } }, "link": { diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 6b4883b8ce3..9f3b1aeec9e 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Netgear device. For example: '192.168.1.1'." } } }, diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index fdc9f01d343..cde02327712 100644 --- a/homeassistant/components/nfandroidtv/strings.json +++ b/homeassistant/components/nfandroidtv/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } } }, diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index eb380cabd04..216b891ac31 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -7,6 +7,9 @@ "port": "[%key:common::config_flow::data::port%]", "token": "[%key:common::config_flow::data::access_token%]", "encrypt_token": "Use an encrypted token for authentication." + }, + "data_description": { + "host": "The hostname or IP address of your Nuki bridge. For example: 192.168.1.25." } }, "reauth_confirm": { diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 2827911a3aa..7347744d56f 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -2,12 +2,15 @@ "config": { "step": { "user": { - "title": "Connect to the NUT server", + "description": "Connect to the NUT server", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your NUT server." } }, "ups": { From 3316f6980d24359693624773ab344f7493def5f6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 4 Dec 2023 10:44:29 +0100 Subject: [PATCH 410/643] Do not fail if Reolink ONVIF cannot be connected (#104947) --- homeassistant/components/reolink/host.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index fe53639822f..dfc77806932 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -165,7 +165,7 @@ class ReolinkHost: if self._onvif_push_supported: try: await self.subscribe() - except NotSupportedError: + except ReolinkError: self._onvif_push_supported = False self.unregister_webhook() await self._api.unsubscribe() From 3cba10fa200ac93bb3dde8569e7c2d8bf269f016 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:44:52 +0100 Subject: [PATCH 411/643] Lifx, Lutron: add host field description (#104855) --- homeassistant/components/lifx/strings.json | 3 +++ homeassistant/components/lutron_caseta/strings.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index c327081fabd..21f3b3fe52b 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LIFX device." } }, "pick_device": { diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index b5ec175d1c9..0fb906f097f 100644 --- a/homeassistant/components/lutron_caseta/strings.json +++ b/homeassistant/components/lutron_caseta/strings.json @@ -11,6 +11,9 @@ "description": "Enter the IP address of the device.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Lutron Caseta Smart Bridge." } }, "link": { From ff84b82027af20f30958c5c3b6fe369f9dee5476 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:45:43 +0100 Subject: [PATCH 412/643] Squeezebox to Synology DSM: add host field description (#104864) Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> Co-authored-by: Jan-Philipp Benecke Co-authored-by: Raj Laud <50647620+rajlaud@users.noreply.github.com> --- homeassistant/components/squeezebox/strings.json | 3 +++ homeassistant/components/switchbee/strings.json | 3 +++ homeassistant/components/synology_dsm/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 87881e3414b..756235ae247 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Logitech Media Server." } }, "edit": { diff --git a/homeassistant/components/switchbee/strings.json b/homeassistant/components/switchbee/strings.json index 2abeee6dd7e..858bda35c0f 100644 --- a/homeassistant/components/switchbee/strings.json +++ b/homeassistant/components/switchbee/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your SwitchBee device." } } }, diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f7ae9c9f238..4ed06119577 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -10,6 +10,9 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Synology NAS." } }, "2sa": { From 7ac8f191bd933f40dc0dc5fec18c4d35151aeaa7 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:46:49 +0100 Subject: [PATCH 413/643] Modern Forms to MyStrom: add host field description (#104856) --- homeassistant/components/modern_forms/strings.json | 3 +++ homeassistant/components/moehlenhoff_alpha2/strings.json | 3 +++ homeassistant/components/mutesync/strings.json | 3 +++ homeassistant/components/mystrom/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/modern_forms/strings.json b/homeassistant/components/modern_forms/strings.json index dd47ef721af..e6d0f6a2206 100644 --- a/homeassistant/components/modern_forms/strings.json +++ b/homeassistant/components/modern_forms/strings.json @@ -6,6 +6,9 @@ "description": "Set up your Modern Forms fan to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Modern Forms fan." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/moehlenhoff_alpha2/strings.json b/homeassistant/components/moehlenhoff_alpha2/strings.json index 3347b2f318c..d15ec9f89eb 100644 --- a/homeassistant/components/moehlenhoff_alpha2/strings.json +++ b/homeassistant/components/moehlenhoff_alpha2/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Möhlenhoff Alpha2 system." } } }, diff --git a/homeassistant/components/mutesync/strings.json b/homeassistant/components/mutesync/strings.json index 2a3cca666ee..b0826384899 100644 --- a/homeassistant/components/mutesync/strings.json +++ b/homeassistant/components/mutesync/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your mutesync device." } } }, diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json index a485a58f5a6..9ebd1c36df0 100644 --- a/homeassistant/components/mystrom/strings.json +++ b/homeassistant/components/mystrom/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your myStrom device." } } }, From 34d01719f25840ef413c2a74382d8537ec882910 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Dec 2023 10:47:49 +0100 Subject: [PATCH 414/643] Minor improvements of deprecation helper (#104980) --- homeassistant/helpers/deprecation.py | 44 +++++++++++++++++++--------- tests/util/yaml/test_init.py | 4 +-- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index c499dd0b6cd..5a0682fdda2 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -99,7 +99,11 @@ def get_deprecated( def deprecated_class( replacement: str, ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark class as deprecated and provide a replacement class to be used instead.""" + """Mark class as deprecated and provide a replacement class to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(cls: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate class as deprecated.""" @@ -107,7 +111,7 @@ def deprecated_class( @functools.wraps(cls) def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original class.""" - _print_deprecation_warning(cls, replacement, "class") + _print_deprecation_warning(cls, replacement, "class", "instantiated") return cls(*args, **kwargs) return deprecated_cls @@ -118,7 +122,11 @@ def deprecated_class( def deprecated_function( replacement: str, ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark function as deprecated and provide a replacement to be used instead.""" + """Mark function as deprecated and provide a replacement to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(func: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate function as deprecated.""" @@ -126,7 +134,7 @@ def deprecated_function( @functools.wraps(func) def deprecated_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original function.""" - _print_deprecation_warning(func, replacement, "function") + _print_deprecation_warning(func, replacement, "function", "called") return func(*args, **kwargs) return deprecated_func @@ -134,10 +142,23 @@ def deprecated_function( return deprecated_decorator -def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None: +def _print_deprecation_warning( + obj: Any, + replacement: str, + description: str, + verb: str, +) -> None: logger = logging.getLogger(obj.__module__) try: integration_frame = get_integration_frame() + except MissingIntegrationFrame: + logger.warning( + "%s is a deprecated %s. Use %s instead", + obj.__name__, + description, + replacement, + ) + else: if integration_frame.custom_integration: hass: HomeAssistant | None = None with suppress(HomeAssistantError): @@ -149,10 +170,11 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> ) logger.warning( ( - "%s was called from %s, this is a deprecated %s. Use %s instead," + "%s was %s from %s, this is a deprecated %s. Use %s instead," " please %s" ), obj.__name__, + verb, integration_frame.integration, description, replacement, @@ -160,16 +182,10 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> ) else: logger.warning( - "%s was called from %s, this is a deprecated %s. Use %s instead", + "%s was %s from %s, this is a deprecated %s. Use %s instead", obj.__name__, + verb, integration_frame.integration, description, replacement, ) - except MissingIntegrationFrame: - logger.warning( - "%s is a deprecated %s. Use %s instead", - obj.__name__, - description, - replacement, - ) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 3a2d9b3734d..6f6f48813ce 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -636,8 +636,8 @@ async def test_deprecated_loaders( ): loader_class() assert ( - f"{loader_class.__name__} was called from hue, this is a deprecated class. " - f"Use {new_class} instead" + f"{loader_class.__name__} was instantiated from hue, this is a deprecated " + f"class. Use {new_class} instead" ) in caplog.text From bf49a3dcc29876185dc90476bb7c3be93e65ec0c Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:48:58 +0100 Subject: [PATCH 415/643] Solar-Log to Soundtouch: add host field description (#104863) --- homeassistant/components/solarlog/strings.json | 3 +++ homeassistant/components/soma/strings.json | 6 ++++-- homeassistant/components/somfy_mylink/strings.json | 3 +++ homeassistant/components/soundtouch/strings.json | 3 +++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index 62e923a766d..5f5e2ae7a5f 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "The prefix to be used for your Solar-Log sensors" + }, + "data_description": { + "host": "The hostname or IP address of your Solar-Log device." } } }, diff --git a/homeassistant/components/soma/strings.json b/homeassistant/components/soma/strings.json index 931a33fff56..abf87b3dde2 100644 --- a/homeassistant/components/soma/strings.json +++ b/homeassistant/components/soma/strings.json @@ -16,8 +16,10 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, - "description": "Please enter connection settings of your SOMA Connect.", - "title": "SOMA Connect" + "data_description": { + "host": "The hostname or IP address of your SOMA Connect." + }, + "description": "Please enter connection settings of your SOMA Connect." } } } diff --git a/homeassistant/components/somfy_mylink/strings.json b/homeassistant/components/somfy_mylink/strings.json index 2609e8d893e..90489c0ba34 100644 --- a/homeassistant/components/somfy_mylink/strings.json +++ b/homeassistant/components/somfy_mylink/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "system_id": "System ID" + }, + "data_description": { + "host": "The hostname or IP address of your Somfy MyLink hub." } } }, diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 7af95aab38c..9fc11f7788a 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bose SoundTouch device." } }, "zeroconf_confirm": { From 0f3cb9b1b63c2d8fabe59da813f04573c0865892 Mon Sep 17 00:00:00 2001 From: Matthias Dunda Date: Mon, 4 Dec 2023 10:53:59 +0100 Subject: [PATCH 416/643] Add telegram message timestamp to event data (#87493) --- homeassistant/components/telegram_bot/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 7d150e95977..1d71e055e2e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -55,6 +55,7 @@ ATTR_CALLBACK_QUERY_ID = "callback_query_id" ATTR_CAPTION = "caption" ATTR_CHAT_ID = "chat_id" ATTR_CHAT_INSTANCE = "chat_instance" +ATTR_DATE = "date" ATTR_DISABLE_NOTIF = "disable_notification" ATTR_DISABLE_WEB_PREV = "disable_web_page_preview" ATTR_EDITED_MSG = "edited_message" @@ -991,6 +992,7 @@ class BaseTelegramBotEntity: event_data: dict[str, Any] = { ATTR_MSGID: message.message_id, ATTR_CHAT_ID: message.chat.id, + ATTR_DATE: message.date, } if Filters.command.filter(message): # This is a command message - set event type to command and split data into command and args From d8a6d864c0f8a02d5d502d8511eab4d92c0e6a85 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 11:48:29 +0100 Subject: [PATCH 417/643] Raise on smtp notification if attachment is not allowed (#104981) * Raise smtp notification if attachment not allowed * Pass url as placeholder * Use variable in err message * Add allow_list as placeholder --- homeassistant/components/smtp/notify.py | 26 +++++++++++++++++----- homeassistant/components/smtp/strings.json | 5 +++++ tests/components/smtp/test_notify.py | 17 ++++++++++---- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 02a5a6408b6..dcc2f49db0f 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -32,6 +32,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -255,13 +256,26 @@ def _attach_file(hass, atch_name, content_id=""): """ try: file_path = Path(atch_name).parent - if not hass.config.is_allowed_path(str(file_path)): - _LOGGER.warning( - "'%s' is not secure to load data from, ignoring attachment '%s'!", - file_path, - atch_name, + if os.path.exists(file_path) and not hass.config.is_allowed_path( + str(file_path) + ): + allow_list = "allowlist_external_dirs" + file_name = os.path.basename(atch_name) + url = "https://www.home-assistant.io/docs/configuration/basic/" + raise ServiceValidationError( + f"Cannot send email with attachment '{file_name} " + f"from directory '{file_path} which is not secure to load data from. " + f"Only folders added to `{allow_list}` are accessible. " + f"See {url} for more information.", + translation_domain=DOMAIN, + translation_key="remote_path_not_allowed", + translation_placeholders={ + "allow_list": allow_list, + "file_path": file_path, + "file_name": file_name, + "url": url, + }, ) - return with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() except FileNotFoundError: diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index b711c2f2009..38dd81ac196 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -4,5 +4,10 @@ "name": "[%key:common::action::reload%]", "description": "Reloads smtp notify services." } + }, + "exceptions": { + "remote_path_not_allowed": { + "message": "Cannot send email with attachment '{file_name} form directory '{file_path} which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." + } } } diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 06110a3e5dc..182b45d9c1b 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -11,6 +11,7 @@ from homeassistant.components.smtp.const import DOMAIN from homeassistant.components.smtp.notify import MailNotificationService from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -111,7 +112,7 @@ EMAIL_DATA = [ ), ( "Test msg", - {"html": HTML, "images": ["test.jpg"]}, + {"html": HTML, "images": ["tests/testing_config/notify/test_not_exists.jpg"]}, "Content-Type: multipart/related", ), ( @@ -156,7 +157,6 @@ def test_send_message( ) def test_sending_insecure_files_fails( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, message_data, data, content_type, @@ -165,10 +165,19 @@ def test_sending_insecure_files_fails( """Verify if we cannot send messages with insecure attachments.""" sample_email = "" message.hass = hass - with patch("email.utils.make_msgid", return_value=sample_email): + with patch("email.utils.make_msgid", return_value=sample_email), pytest.raises( + ServiceValidationError + ) as exc: result, _ = message.send_message(message_data, data=data) assert content_type in result - assert "test.jpg' is not secure to load data from, ignoring attachment" + assert exc.value.translation_key == "remote_path_not_allowed" + assert exc.value.translation_domain == DOMAIN + assert ( + str(exc.value.translation_placeholders["file_path"]) + == "tests/testing_config/notify" + ) + assert exc.value.translation_placeholders["url"] + assert exc.value.translation_placeholders["file_name"] == "test.jpg" def test_send_text_message(hass: HomeAssistant, message) -> None: From db51a8e1f7797ae875c660d3a1b13ef93addd0ee Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Dec 2023 11:52:10 +0100 Subject: [PATCH 418/643] Allow passing breaks_in_ha_version to deprecation helper decorators (#104985) --- homeassistant/helpers/deprecation.py | 26 ++++++++---- homeassistant/util/yaml/loader.py | 4 +- tests/helpers/test_deprecation.py | 59 +++++++++++++++++++++------- tests/util/yaml/test_init.py | 2 +- 4 files changed, 67 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 5a0682fdda2..20dbacde480 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -97,7 +97,7 @@ def get_deprecated( def deprecated_class( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: """Mark class as deprecated and provide a replacement class to be used instead. @@ -111,7 +111,9 @@ def deprecated_class( @functools.wraps(cls) def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original class.""" - _print_deprecation_warning(cls, replacement, "class", "instantiated") + _print_deprecation_warning( + cls, replacement, "class", "instantiated", breaks_in_ha_version + ) return cls(*args, **kwargs) return deprecated_cls @@ -120,7 +122,7 @@ def deprecated_class( def deprecated_function( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: """Mark function as deprecated and provide a replacement to be used instead. @@ -134,7 +136,9 @@ def deprecated_function( @functools.wraps(func) def deprecated_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original function.""" - _print_deprecation_warning(func, replacement, "function", "called") + _print_deprecation_warning( + func, replacement, "function", "called", breaks_in_ha_version + ) return func(*args, **kwargs) return deprecated_func @@ -147,15 +151,21 @@ def _print_deprecation_warning( replacement: str, description: str, verb: str, + breaks_in_ha_version: str | None, ) -> None: logger = logging.getLogger(obj.__module__) + if breaks_in_ha_version: + breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}" + else: + breaks_in = "" try: integration_frame = get_integration_frame() except MissingIntegrationFrame: logger.warning( - "%s is a deprecated %s. Use %s instead", + "%s is a deprecated %s%s. Use %s instead", obj.__name__, description, + breaks_in, replacement, ) else: @@ -170,22 +180,24 @@ def _print_deprecation_warning( ) logger.warning( ( - "%s was %s from %s, this is a deprecated %s. Use %s instead," + "%s was %s from %s, this is a deprecated %s%s. Use %s instead," " please %s" ), obj.__name__, verb, integration_frame.integration, description, + breaks_in, replacement, report_issue, ) else: logger.warning( - "%s was %s from %s, this is a deprecated %s. Use %s instead", + "%s was %s from %s, this is a deprecated %s%s. Use %s instead", obj.__name__, verb, integration_frame.integration, description, + breaks_in, replacement, ) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 275a51cd760..0d06ddfb757 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -137,7 +137,7 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("FastSafeLoader") +@deprecated_class("FastSafeLoader", breaks_in_ha_version="2024.6") class SafeLoader(FastSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" @@ -151,7 +151,7 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("PythonSafeLoader") +@deprecated_class("PythonSafeLoader", breaks_in_ha_version="2024.6") class SafeLineLoader(PythonSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 1216bd6e293..46716263d5b 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -119,32 +119,52 @@ def test_deprecated_class(mock_get_logger) -> None: assert len(mock_logger.warning.mock_calls) == 1 -def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) +def test_deprecated_function( + caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, +) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is not known. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass mock_deprecated_function() assert ( - "mock_deprecated_function is a deprecated function. Use new_function instead" - in caplog.text - ) + f"mock_deprecated_function is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_built_in_integration( caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is built-in. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -170,14 +190,24 @@ def test_deprecated_function_called_from_built_in_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead" in caplog.text - ) + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_custom_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. @@ -186,7 +216,7 @@ def test_deprecated_function_called_from_custom_integration( mock_integration(hass, MockModule("hue"), built_in=False) - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -212,7 +242,8 @@ def test_deprecated_function_called_from_custom_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead, please report it to the author of the 'hue' custom " - "integration" in caplog.text - ) + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead, please report it to the author of the " + "'hue' custom integration" + ) in caplog.text diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 6f6f48813ce..a96d08933ee 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -637,7 +637,7 @@ async def test_deprecated_loaders( loader_class() assert ( f"{loader_class.__name__} was instantiated from hue, this is a deprecated " - f"class. Use {new_class} instead" + f"class which will be removed in HA Core 2024.6. Use {new_class} instead" ) in caplog.text From 7222e2b2d6bf92896e03791f3d44a6a37fcd23fd Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 11:52:54 +0100 Subject: [PATCH 419/643] T-add host field description (#104871) --- homeassistant/components/tellduslive/strings.json | 4 +++- homeassistant/components/tesla_wall_connector/strings.json | 3 +++ homeassistant/components/tplink/strings.json | 3 +++ homeassistant/components/tplink_omada/strings.json | 4 +++- homeassistant/components/tradfri/strings.json | 3 +++ homeassistant/components/twinkly/strings.json | 3 +++ 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index 1dbea7a0e6c..16c847f0077 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -18,7 +18,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, - "title": "Pick endpoint." + "data_description": { + "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for Local API." + } } } }, diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 982894eb17c..97bac988d16 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -6,6 +6,9 @@ "title": "Configure Tesla Wall Connector", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tesla Wall Connector." } } }, diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 750d422cd0d..3b4024c07b4 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your TP-Link device." } }, "pick_device": { diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 6da32cd0c1a..04fa6d162d3 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -8,7 +8,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, - "title": "TP-Link Omada Controller", + "data_description": { + "host": "URL of the management interface of your TP-Link Omada controller." + }, "description": "Enter the connection details for the Omada controller. Cloud controllers aren't supported." }, "site": { diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 0a9a86bd23a..69a28a567ab 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "security_code": "Security Code" + }, + "data_description": { + "host": "Hostname or IP address of your Trådfri gateway." } } }, diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index 9b4c8ebd778..88bc67abbbd 100644 --- a/homeassistant/components/twinkly/strings.json +++ b/homeassistant/components/twinkly/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Twinkly device." } }, "discovery_confirm": { From e8475b9b33eda194deacc9a8621015be9b0d9263 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 12:10:58 +0100 Subject: [PATCH 420/643] Add scaling utils for brightness and fanspeed (#104753) Co-authored-by: Robert Resch --- homeassistant/components/bond/fan.py | 2 +- homeassistant/components/comfoconnect/fan.py | 2 +- homeassistant/components/isy994/fan.py | 2 +- homeassistant/components/knx/fan.py | 2 +- homeassistant/components/modern_forms/fan.py | 2 +- homeassistant/components/mqtt/fan.py | 2 +- .../components/mqtt/light/schema_json.py | 19 +- homeassistant/components/renson/fan.py | 2 +- homeassistant/components/smartthings/fan.py | 2 +- homeassistant/components/smarty/fan.py | 2 +- homeassistant/components/vesync/fan.py | 2 +- homeassistant/components/wemo/fan.py | 2 +- homeassistant/components/zha/fan.py | 2 +- homeassistant/util/color.py | 37 ++ homeassistant/util/percentage.py | 23 +- homeassistant/util/scaling.py | 62 +++ tests/util/snapshots/test_color.ambr | 519 ++++++++++++++++++ tests/util/test_color.py | 137 +++++ tests/util/test_scaling.py | 249 +++++++++ 19 files changed, 1034 insertions(+), 36 deletions(-) create mode 100644 homeassistant/util/scaling.py create mode 100644 tests/util/snapshots/test_color.ambr create mode 100644 tests/util/test_scaling.py diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 3cb81ba40b4..465c4b8966b 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -21,10 +21,10 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .entity import BondEntity diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 3f00a9b59f0..f76ed5939f5 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -22,10 +22,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index e451ef882b4..ebdef4146e0 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -13,10 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import _LOGGER, DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 60db7e95a65..a22a16a6e69 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -14,10 +14,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 9d5a3c32235..e6bcff715b8 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -12,10 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import ( ModernFormsDataUpdateCoordinator, diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index e3dcf66c8b1..24783e171c8 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -31,10 +31,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import subscription from .config import MQTT_RW_SCHEMA diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 3d2957f153d..8702069eab7 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -3,7 +3,7 @@ from __future__ import annotations from contextlib import suppress import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import voluptuous as vol @@ -367,10 +367,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): if brightness_supported(self.supported_color_modes): try: if brightness := values["brightness"]: - scale = self._config[CONF_BRIGHTNESS_SCALE] - self._attr_brightness = min( - 255, - round(brightness * 255 / scale), # type: ignore[operator] + if TYPE_CHECKING: + assert isinstance(brightness, float) + self._attr_brightness = color_util.value_to_brightness( + (1, self._config[CONF_BRIGHTNESS_SCALE]), brightness ) else: _LOGGER.debug( @@ -591,13 +591,12 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._set_flash_and_transition(message, **kwargs) if ATTR_BRIGHTNESS in kwargs and self._config[CONF_BRIGHTNESS]: - brightness_normalized = kwargs[ATTR_BRIGHTNESS] / DEFAULT_BRIGHTNESS_SCALE - brightness_scale = self._config[CONF_BRIGHTNESS_SCALE] - device_brightness = min( - round(brightness_normalized * brightness_scale), brightness_scale + device_brightness = color_util.brightness_to_value( + (1, self._config[CONF_BRIGHTNESS_SCALE]), + kwargs[ATTR_BRIGHTNESS], ) # Make sure the brightness is not rounded down to 0 - device_brightness = max(device_brightness, 1) + device_brightness = max(round(device_brightness), 1) message["brightness"] = device_brightness if self._optimistic: diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index da6850859a6..6bceca92db0 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -13,10 +13,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN from .coordinator import RensonCoordinator diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index ebf80e22909..6c814b781b2 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -12,10 +12,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index cf4b49e6105..d3ba407fa40 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -14,10 +14,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_UPDATE_SMARTY diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 22983054dc9..f0d4d02a9a3 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -11,10 +11,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .common import VeSyncDevice from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index e1c8655c196..39abdba6e82 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -14,10 +14,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import async_wemo_dispatcher_connect from .const import SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index c6b9a104885..7364aed0d1b 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -20,10 +20,10 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .core import discovery from .core.cluster_handlers import wrap_zigpy_exceptions diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 4520a62a5d8..0ab4ac8c6c1 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -7,6 +7,8 @@ from typing import NamedTuple import attr +from .scaling import scale_to_ranged_value + class RGBColor(NamedTuple): """RGB hex values.""" @@ -744,3 +746,38 @@ def check_valid_gamut(Gamut: GamutType) -> bool: ) return not_on_line and red_valid and green_valid and blue_valid + + +def brightness_to_value(low_high_range: tuple[float, float], brightness: int) -> float: + """Given a brightness_scale convert a brightness to a single value. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 255: 100.0 + 127: ~49.8039 + 10: ~3.9216 + """ + return scale_to_ranged_value((1, 255), low_high_range, brightness) + + +def value_to_brightness(low_high_range: tuple[float, float], value: float) -> int: + """Given a brightness_scale convert a single value to a brightness. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 100: 255 + 50: 128 + 4: 10 + + The value will be clamped between 1..255 to ensure valid value. + """ + return min( + 255, + max(1, round(scale_to_ranged_value(low_high_range, (1, 255), value))), + ) diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py index ca5931b2670..cc4835022d3 100644 --- a/homeassistant/util/percentage.py +++ b/homeassistant/util/percentage.py @@ -3,6 +3,13 @@ from __future__ import annotations from typing import TypeVar +from .scaling import ( # noqa: F401 + int_states_in_range, + scale_ranged_value_to_int_range, + scale_to_ranged_value, + states_in_range, +) + _T = TypeVar("_T") @@ -69,8 +76,7 @@ def ranged_value_to_percentage( (1,255), 127: 50 (1,255), 10: 4 """ - offset = low_high_range[0] - 1 - return int(((value - offset) * 100) // states_in_range(low_high_range)) + return scale_ranged_value_to_int_range(low_high_range, (1, 100), value) def percentage_to_ranged_value( @@ -87,15 +93,4 @@ def percentage_to_ranged_value( (1,255), 50: 127.5 (1,255), 4: 10.2 """ - offset = low_high_range[0] - 1 - return states_in_range(low_high_range) * percentage / 100 + offset - - -def states_in_range(low_high_range: tuple[float, float]) -> float: - """Given a range of low and high values return how many states exist.""" - return low_high_range[1] - low_high_range[0] + 1 - - -def int_states_in_range(low_high_range: tuple[float, float]) -> int: - """Given a range of low and high values return how many integer states exist.""" - return int(states_in_range(low_high_range)) + return scale_to_ranged_value((1, 100), low_high_range, percentage) diff --git a/homeassistant/util/scaling.py b/homeassistant/util/scaling.py new file mode 100644 index 00000000000..70e2ac2516a --- /dev/null +++ b/homeassistant/util/scaling.py @@ -0,0 +1,62 @@ +"""Scaling util functions.""" +from __future__ import annotations + + +def scale_ranged_value_to_int_range( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> int: + """Given a range of low and high values convert a single value to another range. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), (1,100), 255: 100 + (1,255), (1,100), 127: 49 + (1,255), (1,100), 10: 3 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return int( + (value - source_offset) + * states_in_range(target_low_high_range) + // states_in_range(source_low_high_range) + + target_offset + ) + + +def scale_to_ranged_value( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> float: + """Given a range of low and high values convert a single value to another range. + + Do not include 0 in a range if 0 means off, + e.g. for brightness or fan speed. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), 255: 100 + (1,255), 127: ~49.8039 + (1,255), 10: ~3.9216 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return (value - source_offset) * ( + states_in_range(target_low_high_range) + ) / states_in_range(source_low_high_range) + target_offset + + +def states_in_range(low_high_range: tuple[float, float]) -> float: + """Given a range of low and high values return how many states exist.""" + return low_high_range[1] - low_high_range[0] + 1 + + +def int_states_in_range(low_high_range: tuple[float, float]) -> int: + """Given a range of low and high values return how many integer states exist.""" + return int(states_in_range(low_high_range)) diff --git a/tests/util/snapshots/test_color.ambr b/tests/util/snapshots/test_color.ambr new file mode 100644 index 00000000000..514502131fb --- /dev/null +++ b/tests/util/snapshots/test_color.ambr @@ -0,0 +1,519 @@ +# serializer version: 1 +# name: test_brightness_to_254_range + dict({ + 1: 0.996078431372549, + 2: 1.992156862745098, + 3: 2.988235294117647, + 4: 3.984313725490196, + 5: 4.980392156862745, + 6: 5.976470588235294, + 7: 6.972549019607843, + 8: 7.968627450980392, + 9: 8.964705882352941, + 10: 9.96078431372549, + 11: 10.95686274509804, + 12: 11.952941176470588, + 13: 12.949019607843137, + 14: 13.945098039215686, + 15: 14.941176470588236, + 16: 15.937254901960785, + 17: 16.933333333333334, + 18: 17.929411764705883, + 19: 18.92549019607843, + 20: 19.92156862745098, + 21: 20.91764705882353, + 22: 21.91372549019608, + 23: 22.909803921568628, + 24: 23.905882352941177, + 25: 24.901960784313726, + 26: 25.898039215686275, + 27: 26.894117647058824, + 28: 27.890196078431373, + 29: 28.886274509803922, + 30: 29.88235294117647, + 31: 30.87843137254902, + 32: 31.87450980392157, + 33: 32.870588235294115, + 34: 33.86666666666667, + 35: 34.86274509803921, + 36: 35.858823529411765, + 37: 36.85490196078431, + 38: 37.85098039215686, + 39: 38.84705882352941, + 40: 39.84313725490196, + 41: 40.83921568627451, + 42: 41.83529411764706, + 43: 42.831372549019605, + 44: 43.82745098039216, + 45: 44.8235294117647, + 46: 45.819607843137256, + 47: 46.8156862745098, + 48: 47.811764705882354, + 49: 48.8078431372549, + 50: 49.80392156862745, + 51: 50.8, + 52: 51.79607843137255, + 53: 52.792156862745095, + 54: 53.78823529411765, + 55: 54.78431372549019, + 56: 55.780392156862746, + 57: 56.77647058823529, + 58: 57.772549019607844, + 59: 58.76862745098039, + 60: 59.76470588235294, + 61: 60.76078431372549, + 62: 61.75686274509804, + 63: 62.752941176470586, + 64: 63.74901960784314, + 65: 64.74509803921569, + 66: 65.74117647058823, + 67: 66.73725490196078, + 68: 67.73333333333333, + 69: 68.72941176470589, + 70: 69.72549019607843, + 71: 70.72156862745098, + 72: 71.71764705882353, + 73: 72.71372549019608, + 74: 73.70980392156862, + 75: 74.70588235294117, + 76: 75.70196078431373, + 77: 76.69803921568628, + 78: 77.69411764705882, + 79: 78.69019607843137, + 80: 79.68627450980392, + 81: 80.68235294117648, + 82: 81.67843137254901, + 83: 82.67450980392157, + 84: 83.67058823529412, + 85: 84.66666666666667, + 86: 85.66274509803921, + 87: 86.65882352941176, + 88: 87.65490196078431, + 89: 88.65098039215687, + 90: 89.6470588235294, + 91: 90.64313725490196, + 92: 91.63921568627451, + 93: 92.63529411764706, + 94: 93.6313725490196, + 95: 94.62745098039215, + 96: 95.62352941176471, + 97: 96.61960784313726, + 98: 97.6156862745098, + 99: 98.61176470588235, + 100: 99.6078431372549, + 101: 100.60392156862746, + 102: 101.6, + 103: 102.59607843137255, + 104: 103.5921568627451, + 105: 104.58823529411765, + 106: 105.58431372549019, + 107: 106.58039215686274, + 108: 107.5764705882353, + 109: 108.57254901960785, + 110: 109.56862745098039, + 111: 110.56470588235294, + 112: 111.56078431372549, + 113: 112.55686274509804, + 114: 113.55294117647058, + 115: 114.54901960784314, + 116: 115.54509803921569, + 117: 116.54117647058824, + 118: 117.53725490196078, + 119: 118.53333333333333, + 120: 119.52941176470588, + 121: 120.52549019607844, + 122: 121.52156862745097, + 123: 122.51764705882353, + 124: 123.51372549019608, + 125: 124.50980392156863, + 126: 125.50588235294117, + 127: 126.50196078431372, + 128: 127.49803921568628, + 129: 128.49411764705883, + 130: 129.49019607843138, + 131: 130.48627450980393, + 132: 131.48235294117646, + 133: 132.478431372549, + 134: 133.47450980392156, + 135: 134.47058823529412, + 136: 135.46666666666667, + 137: 136.46274509803922, + 138: 137.45882352941177, + 139: 138.45490196078433, + 140: 139.45098039215685, + 141: 140.4470588235294, + 142: 141.44313725490196, + 143: 142.4392156862745, + 144: 143.43529411764706, + 145: 144.4313725490196, + 146: 145.42745098039217, + 147: 146.42352941176472, + 148: 147.41960784313724, + 149: 148.4156862745098, + 150: 149.41176470588235, + 151: 150.4078431372549, + 152: 151.40392156862745, + 153: 152.4, + 154: 153.39607843137256, + 155: 154.3921568627451, + 156: 155.38823529411764, + 157: 156.3843137254902, + 158: 157.38039215686274, + 159: 158.3764705882353, + 160: 159.37254901960785, + 161: 160.3686274509804, + 162: 161.36470588235295, + 163: 162.3607843137255, + 164: 163.35686274509803, + 165: 164.35294117647058, + 166: 165.34901960784313, + 167: 166.34509803921569, + 168: 167.34117647058824, + 169: 168.3372549019608, + 170: 169.33333333333334, + 171: 170.3294117647059, + 172: 171.32549019607842, + 173: 172.32156862745097, + 174: 173.31764705882352, + 175: 174.31372549019608, + 176: 175.30980392156863, + 177: 176.30588235294118, + 178: 177.30196078431374, + 179: 178.2980392156863, + 180: 179.2941176470588, + 181: 180.29019607843136, + 182: 181.28627450980392, + 183: 182.28235294117647, + 184: 183.27843137254902, + 185: 184.27450980392157, + 186: 185.27058823529413, + 187: 186.26666666666668, + 188: 187.2627450980392, + 189: 188.25882352941176, + 190: 189.2549019607843, + 191: 190.25098039215686, + 192: 191.24705882352941, + 193: 192.24313725490197, + 194: 193.23921568627452, + 195: 194.23529411764707, + 196: 195.2313725490196, + 197: 196.22745098039215, + 198: 197.2235294117647, + 199: 198.21960784313725, + 200: 199.2156862745098, + 201: 200.21176470588236, + 202: 201.2078431372549, + 203: 202.20392156862746, + 204: 203.2, + 205: 204.19607843137254, + 206: 205.1921568627451, + 207: 206.18823529411765, + 208: 207.1843137254902, + 209: 208.18039215686275, + 210: 209.1764705882353, + 211: 210.17254901960786, + 212: 211.16862745098038, + 213: 212.16470588235293, + 214: 213.1607843137255, + 215: 214.15686274509804, + 216: 215.1529411764706, + 217: 216.14901960784314, + 218: 217.1450980392157, + 219: 218.14117647058825, + 220: 219.13725490196077, + 221: 220.13333333333333, + 222: 221.12941176470588, + 223: 222.12549019607843, + 224: 223.12156862745098, + 225: 224.11764705882354, + 226: 225.1137254901961, + 227: 226.10980392156864, + 228: 227.10588235294117, + 229: 228.10196078431372, + 230: 229.09803921568627, + 231: 230.09411764705882, + 232: 231.09019607843138, + 233: 232.08627450980393, + 234: 233.08235294117648, + 235: 234.07843137254903, + 236: 235.07450980392156, + 237: 236.0705882352941, + 238: 237.06666666666666, + 239: 238.06274509803922, + 240: 239.05882352941177, + 241: 240.05490196078432, + 242: 241.05098039215687, + 243: 242.04705882352943, + 244: 243.04313725490195, + 245: 244.0392156862745, + 246: 245.03529411764706, + 247: 246.0313725490196, + 248: 247.02745098039216, + 249: 248.0235294117647, + 250: 249.01960784313727, + 251: 250.01568627450982, + 252: 251.01176470588234, + 253: 252.0078431372549, + 254: 253.00392156862745, + 255: 254.0, + }) +# --- +# name: test_brightness_to_254_range.1 + dict({ + 0.996078431372549: 1, + 1.992156862745098: 2, + 2.988235294117647: 3, + 3.984313725490196: 4, + 4.980392156862745: 5, + 5.976470588235294: 6, + 6.972549019607843: 7, + 7.968627450980392: 8, + 8.964705882352941: 9, + 9.96078431372549: 10, + 10.95686274509804: 11, + 11.952941176470588: 12, + 12.949019607843137: 13, + 13.945098039215686: 14, + 14.941176470588236: 15, + 15.937254901960785: 16, + 16.933333333333334: 17, + 17.929411764705883: 18, + 18.92549019607843: 19, + 19.92156862745098: 20, + 20.91764705882353: 21, + 21.91372549019608: 22, + 22.909803921568628: 23, + 23.905882352941177: 24, + 24.901960784313726: 25, + 25.898039215686275: 26, + 26.894117647058824: 27, + 27.890196078431373: 28, + 28.886274509803922: 29, + 29.88235294117647: 30, + 30.87843137254902: 31, + 31.87450980392157: 32, + 32.870588235294115: 33, + 33.86666666666667: 34, + 34.86274509803921: 35, + 35.858823529411765: 36, + 36.85490196078431: 37, + 37.85098039215686: 38, + 38.84705882352941: 39, + 39.84313725490196: 40, + 40.83921568627451: 41, + 41.83529411764706: 42, + 42.831372549019605: 43, + 43.82745098039216: 44, + 44.8235294117647: 45, + 45.819607843137256: 46, + 46.8156862745098: 47, + 47.811764705882354: 48, + 48.8078431372549: 49, + 49.80392156862745: 50, + 50.8: 51, + 51.79607843137255: 52, + 52.792156862745095: 53, + 53.78823529411765: 54, + 54.78431372549019: 55, + 55.780392156862746: 56, + 56.77647058823529: 57, + 57.772549019607844: 58, + 58.76862745098039: 59, + 59.76470588235294: 60, + 60.76078431372549: 61, + 61.75686274509804: 62, + 62.752941176470586: 63, + 63.74901960784314: 64, + 64.74509803921569: 65, + 65.74117647058823: 66, + 66.73725490196078: 67, + 67.73333333333333: 68, + 68.72941176470589: 69, + 69.72549019607843: 70, + 70.72156862745098: 71, + 71.71764705882353: 72, + 72.71372549019608: 73, + 73.70980392156862: 74, + 74.70588235294117: 75, + 75.70196078431373: 76, + 76.69803921568628: 77, + 77.69411764705882: 78, + 78.69019607843137: 79, + 79.68627450980392: 80, + 80.68235294117648: 81, + 81.67843137254901: 82, + 82.67450980392157: 83, + 83.67058823529412: 84, + 84.66666666666667: 85, + 85.66274509803921: 86, + 86.65882352941176: 87, + 87.65490196078431: 88, + 88.65098039215687: 89, + 89.6470588235294: 90, + 90.64313725490196: 91, + 91.63921568627451: 92, + 92.63529411764706: 93, + 93.6313725490196: 94, + 94.62745098039215: 95, + 95.62352941176471: 96, + 96.61960784313726: 97, + 97.6156862745098: 98, + 98.61176470588235: 99, + 99.6078431372549: 100, + 100.60392156862746: 101, + 101.6: 102, + 102.59607843137255: 103, + 103.5921568627451: 104, + 104.58823529411765: 105, + 105.58431372549019: 106, + 106.58039215686274: 107, + 107.5764705882353: 108, + 108.57254901960785: 109, + 109.56862745098039: 110, + 110.56470588235294: 111, + 111.56078431372549: 112, + 112.55686274509804: 113, + 113.55294117647058: 114, + 114.54901960784314: 115, + 115.54509803921569: 116, + 116.54117647058824: 117, + 117.53725490196078: 118, + 118.53333333333333: 119, + 119.52941176470588: 120, + 120.52549019607844: 121, + 121.52156862745097: 122, + 122.51764705882353: 123, + 123.51372549019608: 124, + 124.50980392156863: 125, + 125.50588235294117: 126, + 126.50196078431372: 127, + 127.49803921568628: 128, + 128.49411764705883: 129, + 129.49019607843138: 130, + 130.48627450980393: 131, + 131.48235294117646: 132, + 132.478431372549: 133, + 133.47450980392156: 134, + 134.47058823529412: 135, + 135.46666666666667: 136, + 136.46274509803922: 137, + 137.45882352941177: 138, + 138.45490196078433: 139, + 139.45098039215685: 140, + 140.4470588235294: 141, + 141.44313725490196: 142, + 142.4392156862745: 143, + 143.43529411764706: 144, + 144.4313725490196: 145, + 145.42745098039217: 146, + 146.42352941176472: 147, + 147.41960784313724: 148, + 148.4156862745098: 149, + 149.41176470588235: 150, + 150.4078431372549: 151, + 151.40392156862745: 152, + 152.4: 153, + 153.39607843137256: 154, + 154.3921568627451: 155, + 155.38823529411764: 156, + 156.3843137254902: 157, + 157.38039215686274: 158, + 158.3764705882353: 159, + 159.37254901960785: 160, + 160.3686274509804: 161, + 161.36470588235295: 162, + 162.3607843137255: 163, + 163.35686274509803: 164, + 164.35294117647058: 165, + 165.34901960784313: 166, + 166.34509803921569: 167, + 167.34117647058824: 168, + 168.3372549019608: 169, + 169.33333333333334: 170, + 170.3294117647059: 171, + 171.32549019607842: 172, + 172.32156862745097: 173, + 173.31764705882352: 174, + 174.31372549019608: 175, + 175.30980392156863: 176, + 176.30588235294118: 177, + 177.30196078431374: 178, + 178.2980392156863: 179, + 179.2941176470588: 180, + 180.29019607843136: 181, + 181.28627450980392: 182, + 182.28235294117647: 183, + 183.27843137254902: 184, + 184.27450980392157: 185, + 185.27058823529413: 186, + 186.26666666666668: 187, + 187.2627450980392: 188, + 188.25882352941176: 189, + 189.2549019607843: 190, + 190.25098039215686: 191, + 191.24705882352941: 192, + 192.24313725490197: 193, + 193.23921568627452: 194, + 194.23529411764707: 195, + 195.2313725490196: 196, + 196.22745098039215: 197, + 197.2235294117647: 198, + 198.21960784313725: 199, + 199.2156862745098: 200, + 200.21176470588236: 201, + 201.2078431372549: 202, + 202.20392156862746: 203, + 203.2: 204, + 204.19607843137254: 205, + 205.1921568627451: 206, + 206.18823529411765: 207, + 207.1843137254902: 208, + 208.18039215686275: 209, + 209.1764705882353: 210, + 210.17254901960786: 211, + 211.16862745098038: 212, + 212.16470588235293: 213, + 213.1607843137255: 214, + 214.15686274509804: 215, + 215.1529411764706: 216, + 216.14901960784314: 217, + 217.1450980392157: 218, + 218.14117647058825: 219, + 219.13725490196077: 220, + 220.13333333333333: 221, + 221.12941176470588: 222, + 222.12549019607843: 223, + 223.12156862745098: 224, + 224.11764705882354: 225, + 225.1137254901961: 226, + 226.10980392156864: 227, + 227.10588235294117: 228, + 228.10196078431372: 229, + 229.09803921568627: 230, + 230.09411764705882: 231, + 231.09019607843138: 232, + 232.08627450980393: 233, + 233.08235294117648: 234, + 234.07843137254903: 235, + 235.07450980392156: 236, + 236.0705882352941: 237, + 237.06666666666666: 238, + 238.06274509803922: 239, + 239.05882352941177: 240, + 240.05490196078432: 241, + 241.05098039215687: 242, + 242.04705882352943: 243, + 243.04313725490195: 244, + 244.0392156862745: 245, + 245.03529411764706: 246, + 246.0313725490196: 247, + 247.02745098039216: 248, + 248.0235294117647: 249, + 249.01960784313727: 250, + 250.01568627450982: 251, + 251.01176470588234: 252, + 252.0078431372549: 253, + 253.00392156862745: 254, + 254.0: 255, + }) +# --- diff --git a/tests/util/test_color.py b/tests/util/test_color.py index a7e6ba9ab46..5dd20d8d887 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -1,5 +1,8 @@ """Test Home Assistant color util methods.""" +import math + import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol import homeassistant.util.color as color_util @@ -587,3 +590,137 @@ def test_white_levels_to_color_temperature() -> None: 2000, 0, ) + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (530, 255), # test min==255 clamp + (511, 255), + (255, 127), + (49, 24), + (1, 1), + (0, 1), # test max==1 clamp + ], +) +async def test_ranged_value_to_brightness_large(value: float, brightness: int) -> None: + """Test a large scale and clamping and convert a single value to a brightness.""" + scale = (1, 511) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("brightness", "value", "math_ceil"), + [ + (255, 511.0, 511), + (127, 254.49803921568628, 255), + (24, 48.09411764705882, 49), + ], +) +async def test_brightness_to_ranged_value_large( + brightness: int, value: float, math_ceil: int +) -> None: + """Test a large scale and convert a brightness to a single value.""" + scale = (1, 511) + + assert color_util.brightness_to_value(scale, brightness) == value + + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == math_ceil + + +@pytest.mark.parametrize( + ("scale", "value", "brightness"), + [ + ((1, 4), 1, 64), + ((1, 4), 2, 128), + ((1, 4), 3, 191), + ((1, 4), 4, 255), + ((1, 6), 1, 42), + ((1, 6), 2, 85), + ((1, 6), 3, 128), + ((1, 6), 4, 170), + ((1, 6), 5, 212), + ((1, 6), 6, 255), + ], +) +async def test_ranged_value_to_brightness_small( + scale: tuple[float, float], value: float, brightness: int +) -> None: + """Test a small scale and convert a single value to a brightness.""" + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("scale", "brightness", "value"), + [ + ((1, 4), 63, 1), + ((1, 4), 127, 2), + ((1, 4), 191, 3), + ((1, 4), 255, 4), + ((1, 6), 42, 1), + ((1, 6), 85, 2), + ((1, 6), 127, 3), + ((1, 6), 170, 4), + ((1, 6), 212, 5), + ((1, 6), 255, 6), + ], +) +async def test_brightness_to_ranged_value_small( + scale: tuple[float, float], brightness: int, value: float +) -> None: + """Test a small scale and convert a brightness to a single value.""" + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == value + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (101, 2), + (139, 64), + (178, 128), + (217, 192), + (255, 255), + ], +) +async def test_ranged_value_to_brightness_starting_high( + value: float, brightness: int +) -> None: + """Test a range that does not start with 1.""" + scale = (101, 255) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (0, 64), + (1, 128), + (2, 191), + (3, 255), + ], +) +async def test_ranged_value_to_brightness_starting_zero( + value: float, brightness: int +) -> None: + """Test a range that starts with 0.""" + scale = (0, 3) + + assert color_util.value_to_brightness(scale, value) == brightness + + +async def test_brightness_to_254_range(snapshot: SnapshotAssertion) -> None: + """Test brightness scaling to a 254 range and back.""" + brightness_range = range(1, 256) # (1..255) + scale = (1, 254) + scaled_values = { + brightness: color_util.brightness_to_value(scale, brightness) + for brightness in brightness_range + } + assert scaled_values == snapshot + restored_values = {} + for expected_brightness, value in scaled_values.items(): + restored_values[value] = color_util.value_to_brightness(scale, value) + assert color_util.value_to_brightness(scale, value) == expected_brightness + assert restored_values == snapshot diff --git a/tests/util/test_scaling.py b/tests/util/test_scaling.py new file mode 100644 index 00000000000..5fef6cf806b --- /dev/null +++ b/tests/util/test_scaling.py @@ -0,0 +1,249 @@ +"""Test Home Assistant scaling utils.""" + +import math + +import pytest + +from homeassistant.util.percentage import ( + scale_ranged_value_to_int_range, + scale_to_ranged_value, +) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (255, 100), + (127, 49), + (10, 3), + (1, 0), + ], +) +async def test_ranged_value_to_int_range_large( + input_val: float, output_val: int +) -> None: + """Test a large range of low and high values convert a single value to a percentage.""" + source_range = (1, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val", "math_ceil"), + [ + (100, 255, 255), + (50, 127.5, 128), + (4, 10.2, 11), + ], +) +async def test_scale_to_ranged_value_large( + input_val: float, output_val: float, math_ceil: int +) -> None: + """Test a large range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 255) + + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_val + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == math_ceil + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 16), + (2, 33), + (3, 50), + (4, 66), + (5, 83), + (6, 100), + ], +) +async def test_scale_ranged_value_to_int_range_small( + input_val: float, output_val: int +) -> None: + """Test a small range of low and high values convert a single value to a percentage.""" + source_range = (1, 6) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (16, 1), + (33, 2), + (50, 3), + (66, 4), + (83, 5), + (100, 6), + ], +) +async def test_scale_to_ranged_value_small(input_val: float, output_val: int) -> None: + """Test a small range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 6) + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 25), + (2, 50), + (3, 75), + (4, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_at_one( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 1.""" + source_range = (1, 4) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 0), + (139, 25), + (178, 50), + (217, 75), + (255, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high( + input_val: float, output_val: int +) -> None: + """Test a range that does not start with 1.""" + source_range = (101, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 25, 25.0), + (1.0, 50, 50.0), + (2.0, 75, 75.0), + (3.0, 100, 100.0), + ], +) +async def test_scale_ranged_value_to_scaled_range_starting_zero( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a range that starts with 0.""" + source_range = (0, 3) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range( + dest_range, source_range, output_float + ) == int(input_val) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 100), + (139, 125), + (178, 150), + (217, 175), + (255, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high_with_offset( + input_val: float, output_val: int +) -> None: + """Test a ranges that do not start with 1.""" + source_range = (101, 255) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (0, 125), + (1, 150), + (2, 175), + (3, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_offset( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 0 and an other starting high.""" + source_range = (0, 3) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 1, 1.0), + (1.0, 3, 3.0), + (2.0, 5, 5.0), + (3.0, 7, 7.0), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_zero_offset( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a ranges that start with 0. + + In case a range starts with 0, this means value 0 is the first value, + and the values shift -1. + """ + source_range = (0, 3) + dest_range = (0, 7) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range(dest_range, source_range, output_int) == int( + input_val + ) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val From 53becaa976a1caa1249e856ac0fa9da8e9b31e3b Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 4 Dec 2023 03:32:08 -0800 Subject: [PATCH 421/643] Bump opower==0.0.40 (#104986) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 1022ab07e2c..d3a5928150e 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.39"] + "requirements": ["opower==0.0.40"] } diff --git a/requirements_all.txt b/requirements_all.txt index b005cdcc902..5a21b98bd8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1420,7 +1420,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.39 +opower==0.0.40 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9911bdba599..6b3638cc496 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1096,7 +1096,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.39 +opower==0.0.40 # homeassistant.components.oralb oralb-ble==0.17.6 From 95f7db197039efa257b811efea2ea27a277477fe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Dec 2023 12:48:49 +0100 Subject: [PATCH 422/643] Move config_per_platform and extract_domain_configs to config.py (#104989) --- homeassistant/components/automation/config.py | 4 +- .../components/device_tracker/legacy.py | 7 ++- .../components/homeassistant/scene.py | 8 +-- homeassistant/components/mailbox/__init__.py | 7 +-- homeassistant/components/notify/legacy.py | 3 +- homeassistant/components/script/config.py | 4 +- homeassistant/components/stt/legacy.py | 3 +- homeassistant/components/tts/legacy.py | 3 +- homeassistant/config.py | 45 ++++++++++++++--- homeassistant/helpers/__init__.py | 49 ++++++++++++------- homeassistant/helpers/entity_component.py | 4 +- homeassistant/helpers/reload.py | 3 +- tests/helpers/test_init.py | 16 +++++- tests/test_config.py | 33 +++++++++++++ 14 files changed, 138 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index ed801772e6d..ff0fe43ea26 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -11,7 +11,7 @@ from voluptuous.humanize import humanize_error from homeassistant.components import blueprint from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_CONDITION, @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv, script +from homeassistant.helpers import config_validation as cv, script from homeassistant.helpers.condition import async_validate_conditions_config from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index f18f7984e1e..5f2a3c3ba52 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,7 +14,11 @@ import voluptuous as vol from homeassistant import util from homeassistant.backports.functools import cached_property from homeassistant.components import zone -from homeassistant.config import async_log_schema_error, load_yaml_config_file +from homeassistant.config import ( + async_log_schema_error, + config_per_platform, + load_yaml_config_file, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, @@ -33,7 +37,6 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( - config_per_platform, config_validation as cv, discovery, entity_registry as er, diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 3308083f22f..9abfefc996f 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -30,11 +30,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - entity_platform, -) +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform from homeassistant.helpers.service import ( async_extract_entity_ids, @@ -208,7 +204,7 @@ async def async_setup_platform( await platform.async_reset() # Extract only the config for the Home Assistant platform, ignore the rest. - for p_type, p_config in config_per_platform(conf, SCENE_DOMAIN): + for p_type, p_config in conf_util.config_per_platform(conf, SCENE_DOMAIN): if p_type != HA_DOMAIN: continue diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 679abfd3164..623d0f06295 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -13,13 +13,10 @@ from aiohttp.web_exceptions import HTTPNotFound from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView +from homeassistant.config import config_per_platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - discovery, -) +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 110671864e3..30981cd3658 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -6,10 +6,11 @@ from collections.abc import Callable, Coroutine, Mapping from functools import partial from typing import Any, Protocol, cast +from homeassistant.config import config_per_platform from homeassistant.const import CONF_DESCRIPTION, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index c11bb37294f..1cbab23d843 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -13,7 +13,7 @@ from homeassistant.components.blueprint import ( is_blueprint_instance_config, ) from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_DEFAULT, @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import ( SCRIPT_MODE_SINGLE, async_validate_actions_config, diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index 862f59d5f6d..cd5aef312ce 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -6,8 +6,9 @@ from collections.abc import AsyncIterable, Coroutine import logging from typing import Any +from homeassistant.config import config_per_platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_prepare_setup_platform diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 4734c3f22d1..a52bcb802ab 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -18,6 +18,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) +from homeassistant.config import config_per_platform from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DESCRIPTION, @@ -25,7 +26,7 @@ from homeassistant.const import ( CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/config.py b/homeassistant/config.py index b4850e372fd..5d5d246884c 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import OrderedDict -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterable, Sequence from contextlib import suppress from dataclasses import dataclass from enum import StrEnum @@ -48,6 +48,7 @@ from .const import ( CONF_MEDIA_DIRS, CONF_NAME, CONF_PACKAGES, + CONF_PLATFORM, CONF_TEMPERATURE_UNIT, CONF_TIME_ZONE, CONF_TYPE, @@ -58,12 +59,7 @@ from .const import ( from .core import DOMAIN as CONF_CORE, ConfigSource, HomeAssistant, callback from .exceptions import ConfigValidationError, HomeAssistantError from .generated.currencies import HISTORIC_CURRENCIES -from .helpers import ( - config_per_platform, - config_validation as cv, - extract_domain_configs, - issue_registry as ir, -) +from .helpers import config_validation as cv, issue_registry as ir from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType from .loader import ComponentProtocol, Integration, IntegrationNotFound @@ -1222,6 +1218,41 @@ def async_handle_component_errors( ) +def config_per_platform( + config: ConfigType, domain: str +) -> Iterable[tuple[str | None, ConfigType]]: + """Break a component config into different platforms. + + For example, will find 'switch', 'switch 2', 'switch 3', .. etc + Async friendly. + """ + for config_key in extract_domain_configs(config, domain): + if not (platform_config := config[config_key]): + continue + + if not isinstance(platform_config, list): + platform_config = [platform_config] + + item: ConfigType + platform: str | None + for item in platform_config: + try: + platform = item.get(CONF_PLATFORM) + except AttributeError: + platform = None + + yield platform, item + + +def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: + """Extract keys from config for given domain name. + + Async friendly. + """ + pattern = re.compile(rf"^{domain}(| .+)$") + return [key for key in config if pattern.match(key)] + + async def async_process_component_config( # noqa: C901 hass: HomeAssistant, config: ConfigType, diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index c9acdf0d712..52197e83495 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -2,11 +2,8 @@ from __future__ import annotations from collections.abc import Iterable, Sequence -import re from typing import TYPE_CHECKING -from homeassistant.const import CONF_PLATFORM - if TYPE_CHECKING: from .typing import ConfigType @@ -19,22 +16,23 @@ def config_per_platform( For example, will find 'switch', 'switch 2', 'switch 3', .. etc Async friendly. """ - for config_key in extract_domain_configs(config, domain): - if not (platform_config := config[config_key]): - continue + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config - if not isinstance(platform_config, list): - platform_config = [platform_config] + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning - item: ConfigType - platform: str | None - for item in platform_config: - try: - platform = item.get(CONF_PLATFORM) - except AttributeError: - platform = None + _print_deprecation_warning( + config_per_platform, + "config.config_per_platform", + "function", + "called", + "2024.6", + ) + return ha_config.config_per_platform(config, domain) - yield platform, item + +config_per_platform.__name__ = "helpers.config_per_platform" def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: @@ -42,5 +40,20 @@ def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: Async friendly. """ - pattern = re.compile(rf"^{domain}(| .+)$") - return [key for key in config if pattern.match(key)] + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config + + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning + + _print_deprecation_warning( + extract_domain_configs, + "config.extract_domain_configs", + "function", + "called", + "2024.6", + ) + return ha_config.extract_domain_configs(config, domain) + + +extract_domain_configs.__name__ = "helpers.extract_domain_configs" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 775d0934c36..30e892a8840 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -32,7 +32,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform -from . import config_per_platform, config_validation as cv, discovery, entity, service +from . import config_validation as cv, discovery, entity, service from .entity_platform import EntityPlatform from .typing import ConfigType, DiscoveryInfoType @@ -148,7 +148,7 @@ class EntityComponent(Generic[_EntityT]): self.config = config # Look in config for Domain, Domain 2, Domain 3 etc and load them - for p_type, p_config in config_per_platform(config, self.domain): + for p_type, p_config in conf_util.config_per_platform(config, self.domain): if p_type is not None: self.hass.async_create_task( self.async_setup_platform(p_type, p_config), diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 42ebc2d0869..983b4e2da52 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -13,7 +13,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component -from . import config_per_platform from .entity import Entity from .entity_component import EntityComponent from .entity_platform import EntityPlatform, async_get_platforms @@ -69,7 +68,7 @@ async def _resetup_platform( root_config: dict[str, list[ConfigType]] = {platform_domain: []} # Extract only the config for template, ignore the rest. - for p_type, p_config in config_per_platform(conf, platform_domain): + for p_type, p_config in conf_util.config_per_platform(conf, platform_domain): if p_type != integration_domain: continue diff --git a/tests/helpers/test_init.py b/tests/helpers/test_init.py index c567c6bc7bc..39b387000ca 100644 --- a/tests/helpers/test_init.py +++ b/tests/helpers/test_init.py @@ -2,10 +2,12 @@ from collections import OrderedDict +import pytest + from homeassistant import helpers -def test_extract_domain_configs() -> None: +def test_extract_domain_configs(caplog: pytest.LogCaptureFixture) -> None: """Test the extraction of domain configuration.""" config = { "zone": None, @@ -19,8 +21,13 @@ def test_extract_domain_configs() -> None: helpers.extract_domain_configs(config, "zone") ) + assert ( + "helpers.extract_domain_configs is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.extract_domain_configs instead" in caplog.text + ) -def test_config_per_platform() -> None: + +def test_config_per_platform(caplog: pytest.LogCaptureFixture) -> None: """Test config per platform method.""" config = OrderedDict( [ @@ -36,3 +43,8 @@ def test_config_per_platform() -> None: (None, 1), ("hello 2", config["zone Hallo"][1]), ] == list(helpers.config_per_platform(config, "zone")) + + assert ( + "helpers.config_per_platform is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.config_per_platform instead" in caplog.text + ) diff --git a/tests/test_config.py b/tests/test_config.py index de5e7e0581d..1e309e2908f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2207,3 +2207,36 @@ async def test_yaml_error( if record.levelno == logging.ERROR ] assert error_records == snapshot + + +def test_extract_domain_configs() -> None: + """Test the extraction of domain configuration.""" + config = { + "zone": None, + "zoner": None, + "zone ": None, + "zone Hallo": None, + "zone 100": None, + } + + assert {"zone", "zone Hallo", "zone 100"} == set( + config_util.extract_domain_configs(config, "zone") + ) + + +def test_config_per_platform() -> None: + """Test config per platform method.""" + config = OrderedDict( + [ + ("zone", {"platform": "hello"}), + ("zoner", None), + ("zone Hallo", [1, {"platform": "hello 2"}]), + ("zone 100", None), + ] + ) + + assert [ + ("hello", config["zone"]), + (None, 1), + ("hello 2", config["zone Hallo"][1]), + ] == list(config_util.config_per_platform(config, "zone")) From 8661aa96bdea25deaa57d76bd7ab846bf16cacb4 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 13:10:22 +0100 Subject: [PATCH 423/643] U-V add host field description (#104872) Co-authored-by: Simone Chemelli --- homeassistant/components/unifi/strings.json | 3 +++ homeassistant/components/unifiprotect/strings.json | 3 +++ homeassistant/components/v2c/strings.json | 3 +++ homeassistant/components/vallox/strings.json | 3 +++ homeassistant/components/venstar/strings.json | 5 ++++- homeassistant/components/vilfo/strings.json | 3 +++ homeassistant/components/vizio/strings.json | 4 +++- homeassistant/components/vlc_telnet/strings.json | 3 +++ homeassistant/components/vodafone_station/strings.json | 3 +++ homeassistant/components/volumio/strings.json | 3 +++ 10 files changed, 31 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 9c609ca8c07..ba426c2f08a 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "site": "Site ID", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Network." } } }, diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 73ac6e08c17..a345a504c42 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -11,6 +11,9 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Protect device." } }, "reauth_confirm": { diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index a0cf3aae03a..dafdd597e77 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address fo your V2C Trydan EVSE." } } }, diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index acc6a31f158..e3ade9a55c4 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vallox device." } } }, diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index a844adc2156..92dfac211fb 100644 --- a/homeassistant/components/venstar/strings.json +++ b/homeassistant/components/venstar/strings.json @@ -2,13 +2,16 @@ "config": { "step": { "user": { - "title": "Connect to the Venstar Thermostat", + "description": "Connect to the Venstar thermostat", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "pin": "[%key:common::config_flow::data::pin%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your Venstar thermostat." } } }, diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json index d559e3a6716..f2c4c38780b 100644 --- a/homeassistant/components/vilfo/strings.json +++ b/homeassistant/components/vilfo/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vilfo router." } } }, diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 0ff64eeda53..6091cd72f3f 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -2,13 +2,15 @@ "config": { "step": { "user": { - "title": "VIZIO SmartCast Device", "description": "An access token is only needed for TVs. If you are configuring a TV and do not have an access token yet, leave it blank to go through a pairing process.", "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "device_class": "Device Type", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your VIZIO SmartCast device." } }, "pair_tv": { diff --git a/homeassistant/components/vlc_telnet/strings.json b/homeassistant/components/vlc_telnet/strings.json index 3a22bd06602..c0cacc734d3 100644 --- a/homeassistant/components/vlc_telnet/strings.json +++ b/homeassistant/components/vlc_telnet/strings.json @@ -14,6 +14,9 @@ "port": "[%key:common::config_flow::data::port%]", "password": "[%key:common::config_flow::data::password%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your VLC media player." } }, "hassio_confirm": { diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index aaaa27a3614..fab266ac47f 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Vodafone Station." } } }, diff --git a/homeassistant/components/volumio/strings.json b/homeassistant/components/volumio/strings.json index ba283a3af37..32552ad7386 100644 --- a/homeassistant/components/volumio/strings.json +++ b/homeassistant/components/volumio/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "Hostname or IP address of your Volumio media player." } }, "discovery_confirm": { From 157c4e31df1b9a0aa069b51cd9474aa9dc44ad32 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 4 Dec 2023 13:10:51 +0100 Subject: [PATCH 424/643] Update frontend to 20231204.0 (#104990) --- 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 b6668383b54..e254eda0689 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==20231130.0"] + "requirements": ["home-assistant-frontend==20231204.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0ba9076f407..3bfbdc9acd1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ habluetooth==0.5.1 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5a21b98bd8f..0d62effff6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1024,7 +1024,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b3638cc496..b0a05507e13 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -811,7 +811,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From 1629bdcd7ffe57bc9b0734ba5ec45ccb17be6586 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 4 Dec 2023 14:48:40 +0100 Subject: [PATCH 425/643] Remove "swap: none" from modbus (#104713) --- homeassistant/components/modbus/__init__.py | 6 +++--- homeassistant/components/modbus/base_platform.py | 3 --- homeassistant/components/modbus/const.py | 1 - homeassistant/components/modbus/validators.py | 6 ++---- tests/components/modbus/test_sensor.py | 6 ------ 5 files changed, 5 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 14f8b59ddee..46bb5b83731 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -100,7 +100,6 @@ from .const import ( # noqa: F401 CONF_STOPBITS, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, @@ -179,9 +178,10 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( vol.Optional(CONF_SCALE, default=1): number_validator, vol.Optional(CONF_OFFSET, default=0): number_validator, vol.Optional(CONF_PRECISION, default=0): cv.positive_int, - vol.Optional(CONF_SWAP, default=CONF_SWAP_NONE): vol.In( + vol.Optional( + CONF_SWAP, + ): vol.In( [ - CONF_SWAP_NONE, CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index edfca94979e..1458abc0f25 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -55,7 +55,6 @@ from .const import ( CONF_STATE_ON, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VERIFY, @@ -158,8 +157,6 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): """Initialize the switch.""" super().__init__(hub, config) self._swap = config[CONF_SWAP] - if self._swap == CONF_SWAP_NONE: - self._swap = None self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] self._precision = config[CONF_PRECISION] diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index a52f8ccfc97..745793e4057 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -45,7 +45,6 @@ CONF_STEP = "temp_step" CONF_STOPBITS = "stopbits" CONF_SWAP = "swap" CONF_SWAP_BYTE = "byte" -CONF_SWAP_NONE = "none" CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 52919a24ac7..eaf787b3010 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -30,7 +30,6 @@ from .const import ( CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VIRTUAL_COUNT, @@ -115,8 +114,8 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: count = config.get(CONF_COUNT, None) structure = config.get(CONF_STRUCTURE, None) slave_count = config.get(CONF_SLAVE_COUNT, config.get(CONF_VIRTUAL_COUNT)) - swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm + swap_type = config.get(CONF_SWAP) for entry in ( (count, validator.count, CONF_COUNT), (structure, validator.structure, CONF_STRUCTURE), @@ -136,9 +135,8 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: ) raise vol.Invalid(error) - if swap_type != CONF_SWAP_NONE: + if swap_type: swap_type_validator = { - CONF_SWAP_NONE: validator.swap_byte, CONF_SWAP_BYTE: validator.swap_byte, CONF_SWAP_WORD: validator.swap_word, CONF_SWAP_WORD_BYTE: validator.swap_word, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index d0a4e23f780..bb093c24af0 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -19,7 +19,6 @@ from homeassistant.components.modbus.const import ( CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VIRTUAL_COUNT, @@ -125,7 +124,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, } ] }, @@ -228,7 +226,6 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: CONF_ADDRESS: 1234, CONF_DATA_TYPE: DataType.CUSTOM, CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "invalid", }, ] @@ -555,7 +552,6 @@ async def test_config_wrong_struct_sensor( ( { CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, }, [0x0102], False, @@ -1290,7 +1286,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No [ ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT16, }, [0x0102], @@ -1306,7 +1301,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT32, }, [0x0102, 0x0304], From 188d6a6eeeeb5f93f46ae2748afe605b9ea7aeff Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 14:48:56 +0100 Subject: [PATCH 426/643] W-Z: add host field description (#104996) --- homeassistant/components/weatherflow/strings.json | 4 +++- homeassistant/components/webostv/strings.json | 4 +++- homeassistant/components/wled/strings.json | 3 +++ homeassistant/components/yamaha_musiccast/strings.json | 3 +++ homeassistant/components/yardian/strings.json | 3 +++ homeassistant/components/yeelight/strings.json | 3 +++ homeassistant/components/youless/strings.json | 3 +++ homeassistant/components/zeversolar/strings.json | 3 +++ 8 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index 8f7a98abe04..d075ee34a05 100644 --- a/homeassistant/components/weatherflow/strings.json +++ b/homeassistant/components/weatherflow/strings.json @@ -2,10 +2,12 @@ "config": { "step": { "user": { - "title": "WeatherFlow discovery", "description": "Unable to discover Tempest WeatherFlow devices. Click submit to try again.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tempest WeatherFlow device." } } }, diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index a5e7b73e59e..1d045d48ba5 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -3,11 +3,13 @@ "flow_title": "LG webOS Smart TV", "step": { "user": { - "title": "Connect to webOS TV", "description": "Turn on TV, fill the following fields click submit", "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your webOS TV." } }, "pairing": { diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 61b9cc450fe..eff6dfab572 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -6,6 +6,9 @@ "description": "Set up your WLED to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your WLED device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index c4f28fc750b..d0ee6c030a6 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -6,6 +6,9 @@ "description": "Set up MusicCast to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yamaha MusicCast receiver." } }, "confirm": { diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index f841f3d3ed1..fcaef65ee3e 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yardian Smart Sprinkler Controller. You can find it in the Yardian app." } } }, diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index ab22f42dae3..72baec52c85 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yeelight Wi-Fi bulb." } }, "pick_device": { diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index 563e6834ddd..e0eddd7d137 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your YouLess device." } } }, diff --git a/homeassistant/components/zeversolar/strings.json b/homeassistant/components/zeversolar/strings.json index 0e2e23f244c..b75bbe781ef 100644 --- a/homeassistant/components/zeversolar/strings.json +++ b/homeassistant/components/zeversolar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Zeversolar inverter." } } }, From cc0326548ec7ddc6771537d1a1dc9663bf900d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20H=C3=A4rtel?= <60009336+Haerteleric@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:48:44 +0100 Subject: [PATCH 427/643] Add CB3 descriptor to ZHA manifest (#104071) --- homeassistant/components/zha/manifest.json | 6 ++++++ homeassistant/generated/usb.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index cd53772777a..4c8a58a12cf 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -76,6 +76,12 @@ "description": "*conbee*", "known_devices": ["Conbee II"] }, + { + "vid": "0403", + "pid": "6015", + "description": "*conbee*", + "known_devices": ["Conbee III"] + }, { "vid": "10C4", "pid": "8A2A", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index f58936caf8d..2fdd032c2dd 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -81,6 +81,12 @@ USB = [ "pid": "0030", "vid": "1CF1", }, + { + "description": "*conbee*", + "domain": "zha", + "pid": "6015", + "vid": "0403", + }, { "description": "*zigbee*", "domain": "zha", From 7ea4e15ff2570f9d2bd07dd6b9affe07248d5df7 Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 30 Nov 2023 16:40:41 -0500 Subject: [PATCH 428/643] Late review updates for Blink (#104755) --- homeassistant/components/blink/__init__.py | 4 +- homeassistant/components/blink/services.py | 79 ++++++++----- homeassistant/components/blink/strings.json | 17 +++ tests/components/blink/test_services.py | 118 +++++++++----------- 4 files changed, 122 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 42ad5cabeb7..d83c2686563 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -25,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS from .coordinator import BlinkUpdateCoordinator -from .services import async_setup_services +from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -74,7 +74,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Blink.""" - await async_setup_services(hass) + setup_services(hass) return True diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 8ea0b6c03a4..12ac0d3b859 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -1,8 +1,6 @@ """Services for the Blink integration.""" from __future__ import annotations -import logging - import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -14,7 +12,7 @@ from homeassistant.const import ( CONF_PIN, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr @@ -27,56 +25,67 @@ from .const import ( ) from .coordinator import BlinkUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILENAME): cv.string, } ) SERVICE_SEND_PIN_SCHEMA = vol.Schema( - {vol.Required(ATTR_DEVICE_ID): cv.ensure_list, vol.Optional(CONF_PIN): cv.string} + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_PIN): cv.string, + } ) SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILE_PATH): cv.string, } ) -async def async_setup_services(hass: HomeAssistant) -> None: +def setup_services(hass: HomeAssistant) -> None: """Set up the services for the Blink integration.""" - async def collect_coordinators( + def collect_coordinators( device_ids: list[str], ) -> list[BlinkUpdateCoordinator]: - config_entries = list[ConfigEntry]() + config_entries: list[ConfigEntry] = [] registry = dr.async_get(hass) for target in device_ids: device = registry.async_get(target) if device: - device_entries = list[ConfigEntry]() + device_entries: list[ConfigEntry] = [] for entry_id in device.config_entries: entry = hass.config_entries.async_get_entry(entry_id) if entry and entry.domain == DOMAIN: device_entries.append(entry) if not device_entries: - raise HomeAssistantError( - f"Device '{target}' is not a {DOMAIN} device" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device", + translation_placeholders={"target": target, "domain": DOMAIN}, ) config_entries.extend(device_entries) else: raise HomeAssistantError( - f"Device '{target}' not found in device registry" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"target": target}, ) - coordinators = list[BlinkUpdateCoordinator]() + + coordinators: list[BlinkUpdateCoordinator] = [] for config_entry in config_entries: if config_entry.state != ConfigEntryState.LOADED: - raise HomeAssistantError(f"{config_entry.title} is not loaded") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) return coordinators @@ -85,24 +94,36 @@ async def async_setup_services(hass: HomeAssistant) -> None: camera_name = call.data[CONF_NAME] video_path = call.data[CONF_FILENAME] if not hass.config.is_allowed_path(video_path): - _LOGGER.error("Can't write %s, no access to path!", video_path) - return - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": video_path}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): all_cameras = coordinator.api.cameras if camera_name in all_cameras: try: await all_cameras[camera_name].video_to_file(video_path) except OSError as err: - _LOGGER.error("Can't write image to file: %s", err) + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err async def async_handle_save_recent_clips_service(call: ServiceCall) -> None: """Save multiple recent clips to output directory.""" camera_name = call.data[CONF_NAME] clips_dir = call.data[CONF_FILE_PATH] if not hass.config.is_allowed_path(clips_dir): - _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) - return - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": clips_dir}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): all_cameras = coordinator.api.cameras if camera_name in all_cameras: try: @@ -110,11 +131,15 @@ async def async_setup_services(hass: HomeAssistant) -> None: output_dir=clips_dir ) except OSError as err: - _LOGGER.error("Can't write recent clips to directory: %s", err) + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err async def send_pin(call: ServiceCall): """Call blink to send new pin.""" - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.auth.send_auth_key( coordinator.api, call.data[CONF_PIN], @@ -122,7 +147,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def blink_refresh(call: ServiceCall): """Call blink to refresh info.""" - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.refresh(force_cache=True) # Register all the above services diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index c29c4c765b7..f47f72acb9c 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -101,5 +101,22 @@ } } } + }, + "exceptions": { + "invalid_device": { + "message": "Device '{target}' is not a {domain} device" + }, + "device_not_found": { + "message": "Device '{target}' not found in device registry" + }, + "no_path": { + "message": "Can't write to directory {target}, no access to path!" + }, + "cant_write": { + "message": "Can't write to file" + }, + "not_loaded": { + "message": "{target} is not loaded" + } } } diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index 438b47f38c5..ccc326dac1f 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_PIN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -58,7 +58,7 @@ async def test_refresh_service_calls( assert mock_blink_api.refresh.call_count == 2 - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_REFRESH, @@ -66,8 +66,6 @@ async def test_refresh_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - async def test_video_service_calls( hass: HomeAssistant, @@ -90,18 +88,17 @@ async def test_video_service_calls( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - assert "no access to path!" in caplog.text + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=True) caplog.clear() @@ -118,7 +115,7 @@ async def test_video_service_calls( ) mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SAVE_VIDEO, @@ -130,22 +127,19 @@ async def test_video_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - assert "Can't write image" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=False) @@ -171,18 +165,17 @@ async def test_picture_service_calls( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - assert "no access to path!" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=True) mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} @@ -202,21 +195,20 @@ async def test_picture_service_calls( mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( side_effect=OSError ) - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - assert "Can't write recent clips to directory" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SAVE_RECENT_CLIPS, @@ -228,8 +220,6 @@ async def test_picture_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - async def test_pin_service_calls( hass: HomeAssistant, @@ -259,7 +249,7 @@ async def test_pin_service_calls( ) assert mock_blink_api.auth.send_auth_key.assert_awaited_once - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SEND_PIN, @@ -267,8 +257,6 @@ async def test_pin_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - @pytest.mark.parametrize( ("service", "params"), @@ -325,7 +313,7 @@ async def test_service_called_with_non_blink_device( parameters = {ATTR_DEVICE_ID: [device_entry.id]} parameters.update(params) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, service, @@ -333,8 +321,6 @@ async def test_service_called_with_non_blink_device( blocking=True, ) - assert f"Device '{device_entry.id}' is not a blink device" in str(execinfo) - @pytest.mark.parametrize( ("service", "params"), @@ -382,12 +368,10 @@ async def test_service_called_with_unloaded_entry( parameters = {ATTR_DEVICE_ID: [device_entry.id]} parameters.update(params) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, service, parameters, blocking=True, ) - - assert "Mock Title is not loaded" in str(execinfo) From 0d318da9aa481dbc7c3af1ca31d7c1d0b4e0afe2 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 Nov 2023 20:08:58 +0100 Subject: [PATCH 429/643] Update Matter test fixtures to schema version 5 (#104829) --- .../fixtures/config_entry_diagnostics.json | 68 ++++----- .../config_entry_diagnostics_redacted.json | 68 ++++----- .../nodes/color-temperature-light.json | 114 +++++++------- .../fixtures/nodes/device_diagnostics.json | 76 +++++----- .../matter/fixtures/nodes/dimmable-light.json | 80 +++++----- .../fixtures/nodes/door-lock-with-unbolt.json | 142 +++++++++--------- .../matter/fixtures/nodes/door-lock.json | 142 +++++++++--------- .../fixtures/nodes/eve-contact-sensor.json | 120 +++++++-------- .../fixtures/nodes/extended-color-light.json | 114 +++++++------- .../matter/fixtures/nodes/flow-sensor.json | 12 +- .../fixtures/nodes/generic-switch-multi.json | 25 ++- .../matter/fixtures/nodes/generic-switch.json | 12 +- .../fixtures/nodes/humidity-sensor.json | 12 +- .../matter/fixtures/nodes/light-sensor.json | 12 +- .../fixtures/nodes/occupancy-sensor.json | 12 +- .../fixtures/nodes/on-off-plugin-unit.json | 12 +- .../fixtures/nodes/onoff-light-alt-name.json | 72 ++++----- .../fixtures/nodes/onoff-light-no-name.json | 72 ++++----- .../matter/fixtures/nodes/onoff-light.json | 80 +++++----- .../fixtures/nodes/pressure-sensor.json | 12 +- .../matter/fixtures/nodes/switch-unit.json | 12 +- .../fixtures/nodes/temperature-sensor.json | 12 +- .../matter/fixtures/nodes/thermostat.json | 124 +++++++-------- .../fixtures/nodes/window-covering_full.json | 120 +++++++-------- .../fixtures/nodes/window-covering_lift.json | 120 +++++++-------- .../nodes/window-covering_pa-lift.json | 99 ++++++------ .../nodes/window-covering_pa-tilt.json | 120 +++++++-------- .../fixtures/nodes/window-covering_tilt.json | 120 +++++++-------- 28 files changed, 990 insertions(+), 994 deletions(-) diff --git a/tests/components/matter/fixtures/config_entry_diagnostics.json b/tests/components/matter/fixtures/config_entry_diagnostics.json index 53477792e43..f591709fbda 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics.json @@ -40,11 +40,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -76,8 +76,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -122,8 +122,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -155,14 +155,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -503,19 +503,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -540,20 +540,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 8a67ef0fb63..c85ee4d70e3 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -42,11 +42,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -78,8 +78,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -124,8 +124,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -157,14 +157,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -317,19 +317,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -354,20 +354,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/color-temperature-light.json b/tests/components/matter/fixtures/nodes/color-temperature-light.json index 7552fa833fb..45d1c18635c 100644 --- a/tests/components/matter/fixtures/nodes/color-temperature-light.json +++ b/tests/components/matter/fixtures/nodes/color-temperature-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-color-temperature-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 268, - "revision": 1 + "0": 268, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 3abecbdf66f..d95fbe5efa9 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -13,8 +13,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -30,11 +30,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -66,8 +66,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -112,8 +112,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -143,14 +143,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -302,19 +302,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -339,20 +339,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -415,8 +415,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index e14c922857c..7ccc3eef3af 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-dimmable-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json index 6cbd75ab09c..dfa7794f28b 100644 --- a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json +++ b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/door-lock.json b/tests/components/matter/fixtures/nodes/door-lock.json index 1477d78aa67..8a3f0fd68dd 100644 --- a/tests/components/matter/fixtures/nodes/door-lock.json +++ b/tests/components/matter/fixtures/nodes/door-lock.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json index b0eacfb621c..a009796f940 100644 --- a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json +++ b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json @@ -12,16 +12,16 @@ "0/53/47": 0, "0/53/8": [ { - "extAddress": 12872547289273451492, - "rloc16": 1024, - "routerId": 1, - "nextHop": 0, - "pathCost": 0, - "LQIIn": 3, - "LQIOut": 3, - "age": 142, - "allocated": true, - "linkEstablished": true + "0": 12872547289273451492, + "1": 1024, + "2": 1, + "3": 0, + "4": 0, + "5": 3, + "6": 3, + "7": 142, + "8": true, + "9": true } ], "0/53/29": 1556, @@ -30,20 +30,20 @@ "0/53/40": 519, "0/53/7": [ { - "extAddress": 12872547289273451492, - "age": 654, - "rloc16": 1024, - "linkFrameCounter": 738, - "mleFrameCounter": 418, - "lqi": 3, - "averageRssi": -50, - "lastRssi": -51, - "frameErrorRate": 5, - "messageErrorRate": 0, - "rxOnWhenIdle": true, - "fullThreadDevice": true, - "fullNetworkData": true, - "isChild": false + "0": 12872547289273451492, + "1": 654, + "2": 1024, + "3": 738, + "4": 418, + "5": 3, + "6": -50, + "7": -51, + "8": 5, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false } ], "0/53/33": 66, @@ -124,9 +124,9 @@ "0/53/16": 0, "0/42/0": [ { - "providerNodeID": 1773685588, - "endpoint": 0, - "fabricIndex": 1 + "1": 1773685588, + "2": 0, + "254": 1 } ], "0/42/65528": [], @@ -140,8 +140,8 @@ "0/48/65532": 0, "0/48/65528": [1, 3, 5], "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], @@ -158,25 +158,25 @@ "0/31/1": [], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/65532": 0, @@ -187,8 +187,8 @@ "0/49/65533": 1, "0/49/1": [ { - "networkID": "Uv50lWMtT7s=", - "connected": true + "0": "Uv50lWMtT7s=", + "1": true } ], "0/49/3": 20, @@ -217,8 +217,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 46, 48, 49, 51, 53, 60, 62, 63], @@ -226,18 +226,18 @@ "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "ieee802154", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YtmXHFJ/dhk=", - "IPv4Addresses": [], - "IPv6Addresses": [ + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "YtmXHFJ/dhk=", + "5": [], + "6": [ "/RG+U41GAABynlpPU50e5g==", "/oAAAAAAAABg2ZccUn92GQ==", "/VL+dJVjAAB1cwmi02rvTA==" ], - "type": 4 + "7": 4 } ], "0/51/65529": [0], @@ -261,8 +261,8 @@ "0/40/6": "**REDACTED**", "0/40/3": "Eve Door", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/2": 4874, "0/40/65532": 0, @@ -302,8 +302,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 21, - "revision": 1 + "0": 21, + "1": 1 } ], "1/29/65528": [], diff --git a/tests/components/matter/fixtures/nodes/extended-color-light.json b/tests/components/matter/fixtures/nodes/extended-color-light.json index f4d83239b6d..d18b76768ca 100644 --- a/tests/components/matter/fixtures/nodes/extended-color-light.json +++ b/tests/components/matter/fixtures/nodes/extended-color-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-extended-color-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 269, - "revision": 1 + "0": 269, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/flow-sensor.json b/tests/components/matter/fixtures/nodes/flow-sensor.json index e1fc2a36585..a8dad202fa1 100644 --- a/tests/components/matter/fixtures/nodes/flow-sensor.json +++ b/tests/components/matter/fixtures/nodes/flow-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-flow-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 774, - "revision": 1 + "0": 774, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/generic-switch-multi.json b/tests/components/matter/fixtures/nodes/generic-switch-multi.json index 15c93825307..f564e91a1ce 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch-multi.json +++ b/tests/components/matter/fixtures/nodes/generic-switch-multi.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], @@ -77,17 +77,16 @@ "1/59/65528": [], "1/64/0": [ { - "label": "Label", - "value": "1" + "0": "Label", + "1": "1" } ], - "2/3/65529": [0, 64], "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "2/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "2/29/1": [3, 29, 59], @@ -107,8 +106,8 @@ "2/59/65528": [], "2/64/0": [ { - "label": "Label", - "value": "Fancy Button" + "0": "Label", + "1": "Fancy Button" } ] }, diff --git a/tests/components/matter/fixtures/nodes/generic-switch.json b/tests/components/matter/fixtures/nodes/generic-switch.json index 30763c88e5b..80773915748 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch.json +++ b/tests/components/matter/fixtures/nodes/generic-switch.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], diff --git a/tests/components/matter/fixtures/nodes/humidity-sensor.json b/tests/components/matter/fixtures/nodes/humidity-sensor.json index a1940fc1857..8220c9cf8f8 100644 --- a/tests/components/matter/fixtures/nodes/humidity-sensor.json +++ b/tests/components/matter/fixtures/nodes/humidity-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-humidity-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 775, - "revision": 1 + "0": 775, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/light-sensor.json b/tests/components/matter/fixtures/nodes/light-sensor.json index 93583c34292..c4d84bc7923 100644 --- a/tests/components/matter/fixtures/nodes/light-sensor.json +++ b/tests/components/matter/fixtures/nodes/light-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-light-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 262, - "revision": 1 + "0": 262, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/occupancy-sensor.json b/tests/components/matter/fixtures/nodes/occupancy-sensor.json index d8f2580c2b0..f63dd43362b 100644 --- a/tests/components/matter/fixtures/nodes/occupancy-sensor.json +++ b/tests/components/matter/fixtures/nodes/occupancy-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 263, - "revision": 1 + "0": 263, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json index 43ba486bc29..8d523f5443a 100644 --- a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json +++ b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-plugin-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -118,8 +118,8 @@ ], "1/29/0": [ { - "deviceType": 266, - "revision": 1 + "0": 266, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json index f29361da128..3f6e83ca460 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json index 8a1134409a9..18cb68c8926 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light.json b/tests/components/matter/fixtures/nodes/onoff-light.json index 65ef0be5c8e..eed404ff85d 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light.json +++ b/tests/components/matter/fixtures/nodes/onoff-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/pressure-sensor.json b/tests/components/matter/fixtures/nodes/pressure-sensor.json index a47cda28056..d38ac560ac5 100644 --- a/tests/components/matter/fixtures/nodes/pressure-sensor.json +++ b/tests/components/matter/fixtures/nodes/pressure-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-pressure-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 773, - "revision": 1 + "0": 773, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/switch-unit.json b/tests/components/matter/fixtures/nodes/switch-unit.json index ceed22d2524..e16f1e406ec 100644 --- a/tests/components/matter/fixtures/nodes/switch-unit.json +++ b/tests/components/matter/fixtures/nodes/switch-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 99999, - "revision": 1 + "0": 99999, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-switch-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -96,8 +96,8 @@ "1/7/65531": [0, 16, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 9999999, - "revision": 1 + "0": 9999999, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/temperature-sensor.json b/tests/components/matter/fixtures/nodes/temperature-sensor.json index c7d372ac2d7..0abb366f81b 100644 --- a/tests/components/matter/fixtures/nodes/temperature-sensor.json +++ b/tests/components/matter/fixtures/nodes/temperature-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 770, - "revision": 1 + "0": 770, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/thermostat.json b/tests/components/matter/fixtures/nodes/thermostat.json index 85ac42e5429..a7abff41331 100644 --- a/tests/components/matter/fixtures/nodes/thermostat.json +++ b/tests/components/matter/fixtures/nodes/thermostat.json @@ -8,8 +8,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 48, 49, 50, 51, 54, 60, 62, 63, 64], @@ -22,18 +22,18 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -64,8 +64,8 @@ "0/40/17": true, "0/40/18": "3D06D025F9E026A0", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -86,8 +86,8 @@ "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -100,8 +100,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -122,18 +122,18 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "3FR1X7qs", - "IPv4Addresses": ["wKgI7g=="], - "IPv6Addresses": [ + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "3FR1X7qs", + "5": ["wKgI7g=="], + "6": [ "/oAAAAAAAADeVHX//l+6rA==", "JA4DsgZ9jUDeVHX//l+6rA==", "/UgvJAe/AADeVHX//l+6rA==" ], - "type": 1 + "7": 1 } ], "0/51/1": 4, @@ -182,32 +182,32 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", - "vendorID": 4996, - "fabricID": 1, - "nodeID": 1425709672, - "label": "", - "fabricIndex": 1 + "1": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", + "2": 4996, + "3": 1, + "4": 1425709672, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", - "vendorID": 65521, - "fabricID": 1, - "nodeID": 4, - "label": "", - "fabricIndex": 2 + "1": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", + "2": 65521, + "3": 1, + "4": 4, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -233,20 +233,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -275,8 +275,8 @@ "1/6/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 769, - "revision": 1 + "0": 769, + "1": 1 } ], "1/29/1": [3, 4, 6, 29, 30, 64, 513, 514, 516], @@ -295,20 +295,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_full.json b/tests/components/matter/fixtures/nodes/window-covering_full.json index feb75409526..fc6efe2077c 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_full.json +++ b/tests/components/matter/fixtures/nodes/window-covering_full.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-full-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_lift.json b/tests/components/matter/fixtures/nodes/window-covering_lift.json index afc2a2f734f..9c58869e988 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_lift.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-lift-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json index 8d3335bbd6c..fe970b6ed6b 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "7630EF9998EDF03C", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -117,8 +117,8 @@ "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -131,8 +131,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -153,17 +153,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "hPcDB5/k", - "IPv4Addresses": ["wKgIhg=="], - "IPv6Addresses": [ - "/oAAAAAAAACG9wP//gef5A==", - "JA4DsgZ+bsCG9wP//gef5A==" - ], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "hPcDB5/k", + "5": ["wKgIhg=="], + "6": ["/oAAAAAAAACG9wP//gef5A==", "JA4DsgZ+bsCG9wP//gef5A=="], + "7": 1 } ], "0/51/1": 35, @@ -201,19 +198,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 2 + "1": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -239,20 +236,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -281,8 +278,8 @@ "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 514, - "revision": 1 + "0": 514, + "1": 1 } ], "1/29/1": [3, 4, 29, 30, 64, 65, 258], @@ -301,20 +298,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json index 44347dbd964..92a1d820d2e 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock_pa_tilt_window_covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_tilt.json b/tests/components/matter/fixtures/nodes/window-covering_tilt.json index a33e0f24c3f..144348b5c76 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-tilt-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], From 13fdac23c15168f11052082ab220a63006f27197 Mon Sep 17 00:00:00 2001 From: Bartosz Dokurno Date: Mon, 4 Dec 2023 14:58:37 +0100 Subject: [PATCH 430/643] Update Todoist config flow URL (#104992) --- homeassistant/components/todoist/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index b8c79210dfb..94b4ad31826 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -16,7 +16,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -SETTINGS_URL = "https://todoist.com/app/settings/integrations" +SETTINGS_URL = "https://app.todoist.com/app/settings/integrations/developer" STEP_USER_DATA_SCHEMA = vol.Schema( { From 367bbf57094459f025d69edb776bcaef226e9228 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Nov 2023 22:43:34 +0100 Subject: [PATCH 431/643] Use deprecated_class decorator in deprecated YAML loader classes (#104835) --- homeassistant/util/yaml/loader.py | 20 +++----------------- tests/util/yaml/test_init.py | 15 +++++++++------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index e8f4a734bdb..275a51cd760 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -23,7 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.frame import report +from homeassistant.helpers.deprecation import deprecated_class from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -137,17 +137,10 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets +@deprecated_class("FastSafeLoader") class SafeLoader(FastSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - report( - "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - FastSafeLoader.__init__(*args, **kwargs) - class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -158,17 +151,10 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets +@deprecated_class("PythonSafeLoader") class SafeLineLoader(PythonSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - report( - "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - PythonSafeLoader.__init__(*args, **kwargs) - LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index c4e5c58e235..3a2d9b3734d 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -590,7 +590,7 @@ async def test_loading_actual_file_with_syntax_error( def mock_integration_frame() -> Generator[Mock, None, None]: """Mock as if we're calling code from inside an integration.""" correct_frame = Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", + filename="/home/paulus/.homeassistant/custom_components/hue/light.py", lineno="23", line="self.light.is_on", ) @@ -614,12 +614,12 @@ def mock_integration_frame() -> Generator[Mock, None, None]: @pytest.mark.parametrize( - ("loader_class", "message"), + ("loader_class", "new_class"), [ - (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), + (yaml.loader.SafeLoader, "FastSafeLoader"), ( yaml.loader.SafeLineLoader, - "'SafeLineLoader' instead of 'PythonSafeLoader'", + "PythonSafeLoader", ), ], ) @@ -628,14 +628,17 @@ async def test_deprecated_loaders( mock_integration_frame: Mock, caplog: pytest.LogCaptureFixture, loader_class, - message: str, + new_class: str, ) -> None: """Test instantiating the deprecated yaml loaders logs a warning.""" with pytest.raises(TypeError), patch( "homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set() ): loader_class() - assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text + assert ( + f"{loader_class.__name__} was called from hue, this is a deprecated class. " + f"Use {new_class} instead" + ) in caplog.text def test_string_annotated(try_both_loaders) -> None: From 11db0ab1e16dd868730411b0405f5cde5e5d0eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 30 Nov 2023 22:39:54 +0100 Subject: [PATCH 432/643] Bump Mill library (#104836) --- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index cb0ba4522bf..7bb78eb05e7 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.6", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.7", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8e9ad9fb404..b208d1ca486 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1246,7 +1246,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc70a0d4124..892373c2c5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -970,7 +970,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 From 262e59f29323882727e2af27143152538bc7714a Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 30 Nov 2023 16:21:34 -0500 Subject: [PATCH 433/643] Fix Harmony switch removal version (#104838) --- homeassistant/components/harmony/switch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index a3c588c06bb..6b833df9720 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -27,7 +27,7 @@ async def async_setup_entry( hass, DOMAIN, "deprecated_switches", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2024.6.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_switches", @@ -91,7 +91,7 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): self.hass, DOMAIN, f"deprecated_switches_{self.entity_id}_{item}", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2024.6.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_switches_entity", From d67d2d9566ba6c425c77757bcb9c099157cd389c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Nov 2023 23:42:51 +0100 Subject: [PATCH 434/643] Filter out zero readings for DSMR enery sensors (#104843) --- homeassistant/components/dsmr/sensor.py | 4 ++++ tests/components/dsmr/test_sensor.py | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index f56e2c3ed33..0fa04dee489 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -799,6 +799,10 @@ class DSMREntity(SensorEntity): float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) ) + # Make sure we do not return a zero value for an energy sensor + if not value and self.state_class == SensorStateClass.TOTAL_INCREASING: + return None + return value @staticmethod diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 1b7f8efb201..0c71525be48 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -10,6 +10,8 @@ from decimal import Decimal from itertools import chain, repeat from unittest.mock import DEFAULT, MagicMock +import pytest + from homeassistant import config_entries from homeassistant.components.sensor import ( ATTR_OPTIONS, @@ -22,6 +24,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -308,7 +311,17 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) -async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: +@pytest.mark.parametrize( + ("value", "state"), + [ + (Decimal(745.690), "745.69"), + (Decimal(745.695), "745.695"), + (Decimal(0.000), STATE_UNKNOWN), + ], +) +async def test_v5_meter( + hass: HomeAssistant, dsmr_connection_fixture, value: Decimal, state: str +) -> None: """Test if v5 meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -335,7 +348,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": value, "unit": "m3"}, ], ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( @@ -371,7 +384,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == "745.695" + assert gas_consumption.state == state assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) From 074bcc8adc8b845a6cb8fb09e8de76e15e8e5f18 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 1 Dec 2023 15:58:15 +0100 Subject: [PATCH 435/643] Fix handling of unrecognized mimetypes in Synology DSM photos integration (#104848) --- homeassistant/components/synology_dsm/media_source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 16db365f708..3f30fe9b4e9 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -153,8 +153,7 @@ class SynologyPhotosMediaSource(MediaSource): ret = [] for album_item in album_items: mime_type, _ = mimetypes.guess_type(album_item.file_name) - assert isinstance(mime_type, str) - if mime_type.startswith("image/"): + if isinstance(mime_type, str) and mime_type.startswith("image/"): # Force small small thumbnails album_item.thumbnail_size = "sm" ret.append( From 78cf9f2a0186ce8a1433a0144f48a85231c638f3 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:44:52 +0100 Subject: [PATCH 436/643] Lifx, Lutron: add host field description (#104855) --- homeassistant/components/lifx/strings.json | 3 +++ homeassistant/components/lutron_caseta/strings.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index c327081fabd..21f3b3fe52b 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LIFX device." } }, "pick_device": { diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index b5ec175d1c9..0fb906f097f 100644 --- a/homeassistant/components/lutron_caseta/strings.json +++ b/homeassistant/components/lutron_caseta/strings.json @@ -11,6 +11,9 @@ "description": "Enter the IP address of the device.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Lutron Caseta Smart Bridge." } }, "link": { From 1378abab357e3686832490739611a5ac34f87d79 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:46:49 +0100 Subject: [PATCH 437/643] Modern Forms to MyStrom: add host field description (#104856) --- homeassistant/components/modern_forms/strings.json | 3 +++ homeassistant/components/moehlenhoff_alpha2/strings.json | 3 +++ homeassistant/components/mutesync/strings.json | 3 +++ homeassistant/components/mystrom/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/modern_forms/strings.json b/homeassistant/components/modern_forms/strings.json index dd47ef721af..e6d0f6a2206 100644 --- a/homeassistant/components/modern_forms/strings.json +++ b/homeassistant/components/modern_forms/strings.json @@ -6,6 +6,9 @@ "description": "Set up your Modern Forms fan to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Modern Forms fan." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/moehlenhoff_alpha2/strings.json b/homeassistant/components/moehlenhoff_alpha2/strings.json index 3347b2f318c..d15ec9f89eb 100644 --- a/homeassistant/components/moehlenhoff_alpha2/strings.json +++ b/homeassistant/components/moehlenhoff_alpha2/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Möhlenhoff Alpha2 system." } } }, diff --git a/homeassistant/components/mutesync/strings.json b/homeassistant/components/mutesync/strings.json index 2a3cca666ee..b0826384899 100644 --- a/homeassistant/components/mutesync/strings.json +++ b/homeassistant/components/mutesync/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your mutesync device." } } }, diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json index a485a58f5a6..9ebd1c36df0 100644 --- a/homeassistant/components/mystrom/strings.json +++ b/homeassistant/components/mystrom/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your myStrom device." } } }, From 1d04fcc485944cbfa7ffb6478ad65ec587aadd60 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:38:52 +0100 Subject: [PATCH 438/643] Nanoleaf to Nut: add host field description (#104857) Co-authored-by: starkillerOG --- homeassistant/components/nanoleaf/strings.json | 3 +++ homeassistant/components/netgear/strings.json | 3 +++ homeassistant/components/nfandroidtv/strings.json | 3 +++ homeassistant/components/nuki/strings.json | 3 +++ homeassistant/components/nut/strings.json | 5 ++++- 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json index 80eb2ded7d0..13e7c9a11a3 100644 --- a/homeassistant/components/nanoleaf/strings.json +++ b/homeassistant/components/nanoleaf/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Nanoleaf device." } }, "link": { diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 6b4883b8ce3..9f3b1aeec9e 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Netgear device. For example: '192.168.1.1'." } } }, diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index fdc9f01d343..cde02327712 100644 --- a/homeassistant/components/nfandroidtv/strings.json +++ b/homeassistant/components/nfandroidtv/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } } }, diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index eb380cabd04..216b891ac31 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -7,6 +7,9 @@ "port": "[%key:common::config_flow::data::port%]", "token": "[%key:common::config_flow::data::access_token%]", "encrypt_token": "Use an encrypted token for authentication." + }, + "data_description": { + "host": "The hostname or IP address of your Nuki bridge. For example: 192.168.1.25." } }, "reauth_confirm": { diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 2827911a3aa..7347744d56f 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -2,12 +2,15 @@ "config": { "step": { "user": { - "title": "Connect to the NUT server", + "description": "Connect to the NUT server", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your NUT server." } }, "ups": { From 42982de22368649fdc06562febff666780b90cce Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:13:48 +0100 Subject: [PATCH 439/643] Obihai to OpenGarage: add host field description (#104858) Co-authored-by: Jan Bouwhuis --- homeassistant/components/obihai/strings.json | 6 ++++++ homeassistant/components/octoprint/strings.json | 3 +++ homeassistant/components/onewire/strings.json | 3 +++ homeassistant/components/onvif/strings.json | 3 +++ homeassistant/components/opengarage/strings.json | 3 +++ 5 files changed, 18 insertions(+) diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 823bc2e1b8d..f21b4b3706d 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your Obihai device." } }, "dhcp_confirm": { @@ -14,6 +17,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "[%key:component::obihai::config::step::user::data_description::host%]" } } }, diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index c6dbfe6f9c4..63d9753ee1d 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -10,6 +10,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your printer." } }, "reauth_confirm": { diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9e4120b68b2..753f244cfe9 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -12,6 +12,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your 1-Wire device." + }, "title": "Set server details" } } diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index cabab347264..5a36b89688a 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -36,6 +36,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, + "data_description": { + "host": "The hostname or IP address of your ONVIF device." + }, "title": "Configure ONVIF device" }, "configure_profile": { diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json index ba4521d4dcf..f19b458cd0f 100644 --- a/homeassistant/components/opengarage/strings.json +++ b/homeassistant/components/opengarage/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your OpenGarage device." } } }, From f194ffcd52d4f1860ed3edb8705902e649c6d20a Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:18:34 +0100 Subject: [PATCH 440/643] Ping to Qnap: add host field description (#104859) --- homeassistant/components/ping/strings.json | 3 +++ homeassistant/components/progettihwsw/strings.json | 3 +++ homeassistant/components/qnap/strings.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index 31441df7736..12bc1d25c7a 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "count": "Ping count" + }, + "data_description": { + "host": "The hostname or IP address of the device you want to ping." } } }, diff --git a/homeassistant/components/progettihwsw/strings.json b/homeassistant/components/progettihwsw/strings.json index bb98d565594..d50c6f8d4e3 100644 --- a/homeassistant/components/progettihwsw/strings.json +++ b/homeassistant/components/progettihwsw/strings.json @@ -13,6 +13,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your ProgettiHWSW board." } }, "relay_modes": { diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index a5fa3c8a897..d535b9f0e87 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your QNAP device." } } }, From 9827ba7e60ff5ff8c5296dd69ad1dc6dee44e140 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:37:51 +0100 Subject: [PATCH 441/643] Radio Thermostat to Renson: add host field description (#104860) --- homeassistant/components/radiotherm/strings.json | 3 +++ homeassistant/components/rainbird/strings.json | 3 +++ homeassistant/components/rainforest_eagle/strings.json | 3 +++ homeassistant/components/renson/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index 693811f59ab..e76bd2d3f2d 100644 --- a/homeassistant/components/radiotherm/strings.json +++ b/homeassistant/components/radiotherm/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Radio Thermostat." } }, "confirm": { diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 6046189ddc4..ea0d64f6208 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Rain Bird device." } } }, diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index 58c7f6bd795..7b5054bfb0f 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "cloud_id": "Cloud ID", "install_code": "Installation Code" + }, + "data_description": { + "host": "The hostname or IP address of your Rainforest gateway." } } }, diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index d6d03ed1c44..8aa7c6244ea 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Renson Endura delta device." } } }, From 0dc157dc31c946f82c2927311b9f6f42da5fbe85 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:37:02 +0100 Subject: [PATCH 442/643] Reolink to Ruckus: add host field description (#104861) Co-authored-by: starkillerOG --- homeassistant/components/reolink/strings.json | 3 +++ homeassistant/components/rfxtrx/strings.json | 3 +++ homeassistant/components/roomba/strings.json | 6 ++++++ homeassistant/components/ruckus_unleashed/strings.json | 3 +++ 4 files changed, 15 insertions(+) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5b26d70b657..5a27f0e38cb 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -10,6 +10,9 @@ "use_https": "Enable HTTPS", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'." } }, "reauth_confirm": { diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 85ddf559cf5..9b99553d3f0 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -19,6 +19,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your RFXCOM RFXtrx device." + }, "title": "Select connection address" }, "setup_serial": { diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index f1816d58613..654c1b7fdfc 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -7,6 +7,9 @@ "description": "Select a Roomba or Braava.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "manual": { @@ -14,6 +17,9 @@ "description": "No Roomba or Braava have been discovered on your network.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "link": { diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json index 769cde67d7a..65a39e5e218 100644 --- a/homeassistant/components/ruckus_unleashed/strings.json +++ b/homeassistant/components/ruckus_unleashed/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ruckus access point." } } }, From 0cf4c6e568540371de83386ca342f9d244bd7302 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Fri, 1 Dec 2023 16:45:53 +0100 Subject: [PATCH 443/643] SamsungTV to Snapcast: add host field description (#104862) --- homeassistant/components/samsungtv/strings.json | 3 +++ homeassistant/components/sfr_box/strings.json | 3 +++ homeassistant/components/sma/strings.json | 3 +++ homeassistant/components/snapcast/strings.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index f1f237fa4fb..c9d08f756d0 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } }, "confirm": { diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 7ea18304164..6f0001e97ce 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -26,6 +26,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, + "data_description": { + "host": "The hostname or IP address of your SFR device." + }, "description": "Setting the credentials is optional, but enables additional functionality." } } diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index f5dc6c16c88..16e5d7408c4 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -19,6 +19,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "host": "The hostname or IP address of your SMA device." + }, "description": "Enter your SMA device information.", "title": "Set up SMA Solar" } diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 0d51c7543f1..b5673910595 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your Snapcast server." + }, "title": "[%key:common::action::connect%]" } }, From 8fd9761e7d182dba9c97381f557788446276fc89 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:48:58 +0100 Subject: [PATCH 444/643] Solar-Log to Soundtouch: add host field description (#104863) --- homeassistant/components/solarlog/strings.json | 3 +++ homeassistant/components/soma/strings.json | 6 ++++-- homeassistant/components/somfy_mylink/strings.json | 3 +++ homeassistant/components/soundtouch/strings.json | 3 +++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index 62e923a766d..5f5e2ae7a5f 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "The prefix to be used for your Solar-Log sensors" + }, + "data_description": { + "host": "The hostname or IP address of your Solar-Log device." } } }, diff --git a/homeassistant/components/soma/strings.json b/homeassistant/components/soma/strings.json index 931a33fff56..abf87b3dde2 100644 --- a/homeassistant/components/soma/strings.json +++ b/homeassistant/components/soma/strings.json @@ -16,8 +16,10 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, - "description": "Please enter connection settings of your SOMA Connect.", - "title": "SOMA Connect" + "data_description": { + "host": "The hostname or IP address of your SOMA Connect." + }, + "description": "Please enter connection settings of your SOMA Connect." } } } diff --git a/homeassistant/components/somfy_mylink/strings.json b/homeassistant/components/somfy_mylink/strings.json index 2609e8d893e..90489c0ba34 100644 --- a/homeassistant/components/somfy_mylink/strings.json +++ b/homeassistant/components/somfy_mylink/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "system_id": "System ID" + }, + "data_description": { + "host": "The hostname or IP address of your Somfy MyLink hub." } } }, diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 7af95aab38c..9fc11f7788a 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bose SoundTouch device." } }, "zeroconf_confirm": { From 39026e3b53fb940edd01bc329ebb003c84cae6fd Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 1 Dec 2023 12:26:18 +0100 Subject: [PATCH 445/643] Reolink schedule update after firmware update (#104867) --- homeassistant/components/reolink/update.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index a75af46e81e..ffd429e92ad 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -1,6 +1,7 @@ """Update entities for Reolink devices.""" from __future__ import annotations +from datetime import datetime import logging from typing import Any, Literal @@ -13,9 +14,10 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later from . import ReolinkData from .const import DOMAIN @@ -23,6 +25,8 @@ from .entity import ReolinkBaseCoordinatorEntity LOGGER = logging.getLogger(__name__) +POLL_AFTER_INSTALL = 120 + async def async_setup_entry( hass: HomeAssistant, @@ -51,6 +55,7 @@ class ReolinkUpdateEntity( super().__init__(reolink_data, reolink_data.firmware_coordinator) self._attr_unique_id = f"{self._host.unique_id}" + self._cancel_update: CALLBACK_TYPE | None = None @property def installed_version(self) -> str | None: @@ -100,3 +105,16 @@ class ReolinkUpdateEntity( ) from err finally: self.async_write_ha_state() + self._cancel_update = async_call_later( + self.hass, POLL_AFTER_INSTALL, self._async_update_future + ) + + async def _async_update_future(self, now: datetime | None = None) -> None: + """Request update.""" + await self.async_update() + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + await super().async_will_remove_from_hass() + if self._cancel_update is not None: + self._cancel_update() From 555e413edbd103cd287fda694b0462b5a587473f Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 11:52:54 +0100 Subject: [PATCH 446/643] T-add host field description (#104871) --- homeassistant/components/tellduslive/strings.json | 4 +++- homeassistant/components/tesla_wall_connector/strings.json | 3 +++ homeassistant/components/tplink/strings.json | 3 +++ homeassistant/components/tplink_omada/strings.json | 4 +++- homeassistant/components/tradfri/strings.json | 3 +++ homeassistant/components/twinkly/strings.json | 3 +++ 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index 1dbea7a0e6c..16c847f0077 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -18,7 +18,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, - "title": "Pick endpoint." + "data_description": { + "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for Local API." + } } } }, diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 982894eb17c..97bac988d16 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -6,6 +6,9 @@ "title": "Configure Tesla Wall Connector", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tesla Wall Connector." } } }, diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 750d422cd0d..3b4024c07b4 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your TP-Link device." } }, "pick_device": { diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 6da32cd0c1a..04fa6d162d3 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -8,7 +8,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, - "title": "TP-Link Omada Controller", + "data_description": { + "host": "URL of the management interface of your TP-Link Omada controller." + }, "description": "Enter the connection details for the Omada controller. Cloud controllers aren't supported." }, "site": { diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 0a9a86bd23a..69a28a567ab 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "security_code": "Security Code" + }, + "data_description": { + "host": "Hostname or IP address of your Trådfri gateway." } } }, diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index 9b4c8ebd778..88bc67abbbd 100644 --- a/homeassistant/components/twinkly/strings.json +++ b/homeassistant/components/twinkly/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Twinkly device." } }, "discovery_confirm": { From 9181d655f98f2ae0f6dd229dfc82d63fc32cbdf3 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 13:10:22 +0100 Subject: [PATCH 447/643] U-V add host field description (#104872) Co-authored-by: Simone Chemelli --- homeassistant/components/unifi/strings.json | 3 +++ homeassistant/components/unifiprotect/strings.json | 3 +++ homeassistant/components/v2c/strings.json | 3 +++ homeassistant/components/vallox/strings.json | 3 +++ homeassistant/components/venstar/strings.json | 5 ++++- homeassistant/components/vilfo/strings.json | 3 +++ homeassistant/components/vizio/strings.json | 4 +++- homeassistant/components/vlc_telnet/strings.json | 3 +++ homeassistant/components/vodafone_station/strings.json | 3 +++ homeassistant/components/volumio/strings.json | 3 +++ 10 files changed, 31 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 9c609ca8c07..ba426c2f08a 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "site": "Site ID", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Network." } } }, diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 73ac6e08c17..a345a504c42 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -11,6 +11,9 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Protect device." } }, "reauth_confirm": { diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index a0cf3aae03a..dafdd597e77 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address fo your V2C Trydan EVSE." } } }, diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index acc6a31f158..e3ade9a55c4 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vallox device." } } }, diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index a844adc2156..92dfac211fb 100644 --- a/homeassistant/components/venstar/strings.json +++ b/homeassistant/components/venstar/strings.json @@ -2,13 +2,16 @@ "config": { "step": { "user": { - "title": "Connect to the Venstar Thermostat", + "description": "Connect to the Venstar thermostat", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "pin": "[%key:common::config_flow::data::pin%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your Venstar thermostat." } } }, diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json index d559e3a6716..f2c4c38780b 100644 --- a/homeassistant/components/vilfo/strings.json +++ b/homeassistant/components/vilfo/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vilfo router." } } }, diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 0ff64eeda53..6091cd72f3f 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -2,13 +2,15 @@ "config": { "step": { "user": { - "title": "VIZIO SmartCast Device", "description": "An access token is only needed for TVs. If you are configuring a TV and do not have an access token yet, leave it blank to go through a pairing process.", "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "device_class": "Device Type", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your VIZIO SmartCast device." } }, "pair_tv": { diff --git a/homeassistant/components/vlc_telnet/strings.json b/homeassistant/components/vlc_telnet/strings.json index 3a22bd06602..c0cacc734d3 100644 --- a/homeassistant/components/vlc_telnet/strings.json +++ b/homeassistant/components/vlc_telnet/strings.json @@ -14,6 +14,9 @@ "port": "[%key:common::config_flow::data::port%]", "password": "[%key:common::config_flow::data::password%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your VLC media player." } }, "hassio_confirm": { diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index aaaa27a3614..fab266ac47f 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Vodafone Station." } } }, diff --git a/homeassistant/components/volumio/strings.json b/homeassistant/components/volumio/strings.json index ba283a3af37..32552ad7386 100644 --- a/homeassistant/components/volumio/strings.json +++ b/homeassistant/components/volumio/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "Hostname or IP address of your Volumio media player." } }, "discovery_confirm": { From cda7863a451c878f74a8bcfd109639152c21bb0a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 09:36:41 +0100 Subject: [PATCH 448/643] Link second Hue host field description (#104885) --- homeassistant/components/hue/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 122cb489d26..114f501d7a3 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -16,7 +16,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of your Hue bridge." + "host": "[%key:component::hue::config::step::init::data_description::host%]" } }, "link": { From 380e71d1b2a3bc3a0b0dcc7b76c5f2b187f72bfa Mon Sep 17 00:00:00 2001 From: Patrick Decat Date: Mon, 4 Dec 2023 09:45:59 +0100 Subject: [PATCH 449/643] Fix incompatible 'measurement' state and 'volume' device class warnings in Overkiz (#104896) --- homeassistant/components/overkiz/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 41c2f4d1a92..0bb9043c040 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -100,7 +100,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Water volume estimation at 40 °C", icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.VOLUME_STORAGE, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), @@ -110,7 +110,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.IO_OUTLET_ENGINE, From e1142e2ad8410a9498e18ec9e10ca4b29fd22603 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 2 Dec 2023 19:28:56 +0100 Subject: [PATCH 450/643] Fix dsmr zero reconnect interval option could crash HA (#104900) * Fix dsmr zero interval option could crash HA * No change change the options --- homeassistant/components/dsmr/const.py | 1 - homeassistant/components/dsmr/sensor.py | 9 ++------- tests/components/dsmr/test_config_flow.py | 1 - tests/components/dsmr/test_init.py | 1 - tests/components/dsmr/test_mbus_migration.py | 2 -- tests/components/dsmr/test_sensor.py | 18 ++---------------- 6 files changed, 4 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index ec0623a9ed6..45332546195 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -12,7 +12,6 @@ LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" CONF_PROTOCOL = "protocol" -CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_PRECISION = "precision" CONF_TIME_BETWEEN_UPDATE = "time_between_update" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 0fa04dee489..b128f9d3baa 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -48,7 +48,6 @@ from .const import ( CONF_DSMR_VERSION, CONF_PRECISION, CONF_PROTOCOL, - CONF_RECONNECT_INTERVAL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, @@ -647,9 +646,7 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) except (serial.serialutil.SerialException, OSError): # Log any error while establishing connection and drop to retry @@ -663,9 +660,7 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) except CancelledError: # Reflect disconnect state in devices state by setting an # None telegram resulting in `unavailable` states diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 55395b92270..5c34fbd9e35 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -475,7 +475,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, } entry = MockConfigEntry( diff --git a/tests/components/dsmr/test_init.py b/tests/components/dsmr/test_init.py index 512e0822016..231cd65d768 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -99,7 +99,6 @@ async def test_migrate_unique_id( "port": "/dev/ttyUSB0", "dsmr_version": dsmr_version, "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", }, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 493fd93259f..99513b9a2a8 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -30,7 +30,6 @@ async def test_migrate_gas_to_mbus( "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, @@ -128,7 +127,6 @@ async def test_migrate_gas_to_mbus_exists( "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 0c71525be48..d3bfabdc0c6 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -52,7 +52,6 @@ async def test_default_setup( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -190,7 +189,6 @@ async def test_setup_only_energy( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", } entry_options = { @@ -246,7 +244,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "4", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -335,7 +332,6 @@ async def test_v5_meter( "port": "/dev/ttyUSB0", "dsmr_version": "5", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -411,7 +407,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> "port": "/dev/ttyUSB0", "dsmr_version": "5L", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -515,7 +510,6 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -717,7 +711,6 @@ async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) - "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -880,7 +873,6 @@ async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": None, } @@ -992,7 +984,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - "port": "/dev/ttyUSB0", "dsmr_version": "5B", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1047,7 +1038,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No "port": "/dev/ttyUSB0", "dsmr_version": "5S", "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -1122,7 +1112,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -1196,7 +1185,6 @@ async def test_tcp(hass: HomeAssistant, dsmr_connection_fixture) -> None: "dsmr_version": "2.2", "protocol": "dsmr_protocol", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1224,7 +1212,6 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1242,6 +1229,7 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - assert connection_factory.call_args_list[0][0][1] == "1234" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_connection_errors_retry( hass: HomeAssistant, dsmr_connection_fixture ) -> None: @@ -1252,7 +1240,6 @@ async def test_connection_errors_retry( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1281,6 +1268,7 @@ async def test_connection_errors_retry( assert first_fail_connection_factory.call_count >= 2, "connecting not retried" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: """If transport disconnects, the connection should be retried.""" from dsmr_parser.obis_references import ( @@ -1295,7 +1283,6 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1378,7 +1365,6 @@ async def test_gas_meter_providing_energy_reading( "port": "/dev/ttyUSB0", "dsmr_version": "2.2", "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } From f5fae54c3227695eb32a1dc117e093039970a7eb Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 2 Dec 2023 16:35:52 +0100 Subject: [PATCH 451/643] Fix get_events name in calendar strings (#104902) --- homeassistant/components/calendar/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 57450000199..78b8407240c 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -73,7 +73,7 @@ } }, "get_events": { - "name": "Get event", + "name": "Get events", "description": "Get events on a calendar within a time range.", "fields": { "start_date_time": { From b53b1ab614e02cf419832d9492129ea532f0db5b Mon Sep 17 00:00:00 2001 From: Alex Thompson Date: Sun, 3 Dec 2023 16:13:26 -0500 Subject: [PATCH 452/643] Fix Lyric HVAC mode reset on temperature change (#104910) * Fix Lyric HVAC mode reset on temperature change * Reduce code duplication * Revert additional bugfix Co-authored-by: Jan Bouwhuis --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/lyric/climate.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index d0bad55ff14..f01e4c4fe55 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -324,6 +324,15 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): "Could not find target_temp_low and/or target_temp_high in" " arguments" ) + + # If the device supports "Auto" mode, don't pass the mode when setting the + # temperature + mode = ( + None + if device.changeableValues.mode == LYRIC_HVAC_MODE_HEAT_COOL + else HVAC_MODES[device.changeableValues.heatCoolMode] + ) + _LOGGER.debug("Set temperature: %s - %s", target_temp_low, target_temp_high) try: await self._update_thermostat( @@ -331,7 +340,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): device, coolSetpoint=target_temp_high, heatSetpoint=target_temp_low, - mode=HVAC_MODES[device.changeableValues.heatCoolMode], + mode=mode, ) except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) From 214f21412254ca6cf334dcef3546c0955dd238a1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 3 Dec 2023 11:57:48 +0100 Subject: [PATCH 453/643] Only raise issue if switch used in Logitech Harmony Hub (#104941) --- homeassistant/components/harmony/switch.py | 27 ++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index 6b833df9720..2d072f11f2c 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -23,15 +23,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up harmony activity switches.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_switches", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_switches", - ) data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] activities = data.activities @@ -65,10 +56,28 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Start this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_start_activity(self._activity_name) async def async_turn_off(self, **kwargs: Any) -> None: """Stop this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_power_off() async def async_added_to_hass(self) -> None: From 7d21ed41a2250bf4f8af22691579927acfb681f3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 14:59:51 +0100 Subject: [PATCH 454/643] Refactor lock default code handling (#104807) --- homeassistant/components/lock/__init__.py | 72 +++++++++--------- tests/components/lock/test_init.py | 91 ++++++++++------------- 2 files changed, 75 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index ed7e2070055..ca91236a77c 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -33,7 +33,6 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.service import remove_entity_service_fields from homeassistant.helpers.typing import ConfigType, StateType _LOGGER = logging.getLogger(__name__) @@ -75,48 +74,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) component.async_register_entity_service( - SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, _async_unlock + SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service" ) component.async_register_entity_service( - SERVICE_LOCK, LOCK_SERVICE_SCHEMA, _async_lock + SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service" ) component.async_register_entity_service( - SERVICE_OPEN, LOCK_SERVICE_SCHEMA, _async_open, [LockEntityFeature.OPEN] + SERVICE_OPEN, + LOCK_SERVICE_SCHEMA, + "async_handle_open_service", + [LockEntityFeature.OPEN], ) return True -@callback -def _add_default_code(entity: LockEntity, service_call: ServiceCall) -> dict[Any, Any]: - data = remove_entity_service_fields(service_call) - code: str = data.pop(ATTR_CODE, "") - if not code: - code = entity._lock_option_default_code # pylint: disable=protected-access - if entity.code_format_cmp and not entity.code_format_cmp.match(code): - raise ValueError( - f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}" - ) - if code: - data[ATTR_CODE] = code - return data - - -async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: - """Lock the lock.""" - await entity.async_lock(**_add_default_code(entity, service_call)) - - -async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: - """Unlock the lock.""" - await entity.async_unlock(**_add_default_code(entity, service_call)) - - -async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: - """Open the door latch.""" - await entity.async_open(**_add_default_code(entity, service_call)) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent[LockEntity] = hass.data[DOMAIN] @@ -149,6 +121,21 @@ class LockEntity(Entity): _lock_option_default_code: str = "" __code_format_cmp: re.Pattern[str] | None = None + @final + @callback + def add_default_code(self, data: dict[Any, Any]) -> dict[Any, Any]: + """Add default lock code.""" + code: str = data.pop(ATTR_CODE, "") + if not code: + code = self._lock_option_default_code + if self.code_format_cmp and not self.code_format_cmp.match(code): + raise ValueError( + f"Code '{code}' for locking {self.entity_id} doesn't match pattern {self.code_format}" + ) + if code: + data[ATTR_CODE] = code + return data + @property def changed_by(self) -> str | None: """Last change triggered by.""" @@ -193,6 +180,11 @@ class LockEntity(Entity): """Return true if the lock is jammed (incomplete locking).""" return self._attr_is_jammed + @final + async def async_handle_lock_service(self, **kwargs: Any) -> None: + """Add default code and lock.""" + await self.async_lock(**self.add_default_code(kwargs)) + def lock(self, **kwargs: Any) -> None: """Lock the lock.""" raise NotImplementedError() @@ -201,6 +193,11 @@ class LockEntity(Entity): """Lock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.lock, **kwargs)) + @final + async def async_handle_unlock_service(self, **kwargs: Any) -> None: + """Add default code and unlock.""" + await self.async_unlock(**self.add_default_code(kwargs)) + def unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" raise NotImplementedError() @@ -209,6 +206,11 @@ class LockEntity(Entity): """Unlock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.unlock, **kwargs)) + @final + async def async_handle_open_service(self, **kwargs: Any) -> None: + """Add default code and open.""" + await self.async_open(**self.add_default_code(kwargs)) + def open(self, **kwargs: Any) -> None: """Open the door latch.""" raise NotImplementedError() diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 16f40fda786..637acc22d05 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -9,10 +9,6 @@ import pytest from homeassistant.components.lock import ( ATTR_CODE, CONF_DEFAULT_CODE, - DOMAIN, - SERVICE_LOCK, - SERVICE_OPEN, - SERVICE_UNLOCK, STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, @@ -20,11 +16,8 @@ from homeassistant.components.lock import ( STATE_UNLOCKING, LockEntity, LockEntityFeature, - _async_lock, - _async_open, - _async_unlock, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component @@ -87,7 +80,7 @@ async def test_lock_states(hass: HomeAssistant) -> None: assert lock.is_locking assert lock.state == STATE_LOCKING - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() assert lock.is_locked assert lock.state == STATE_LOCKED @@ -95,7 +88,7 @@ async def test_lock_states(hass: HomeAssistant) -> None: assert lock.is_unlocking assert lock.state == STATE_UNLOCKING - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() assert not lock.is_locked assert lock.state == STATE_UNLOCKED @@ -189,12 +182,12 @@ async def test_lock_open_with_code(hass: HomeAssistant) -> None: assert lock.state_attributes == {"code_format": r"^\d{4}$"} with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "HELLO"})) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "1234"})) + await lock.async_handle_open_service(code="HELLO") + await lock.async_handle_open_service(code="1234") assert lock.calls_open.call_count == 1 @@ -203,16 +196,16 @@ async def test_lock_lock_with_code(hass: HomeAssistant) -> None: lock = MockLockEntity(code_format=r"^\d{4}$") lock.hass = hass - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_unlock_service(code="1234") assert not lock.is_locked with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "HELLO"})) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_lock_service(code="HELLO") + await lock.async_handle_lock_service(code="1234") assert lock.is_locked @@ -221,18 +214,16 @@ async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: lock = MockLockEntity(code_format=r"^\d{4}$") lock.hass = hass - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_lock_service(code="1234") assert lock.is_locked with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") with pytest.raises(ValueError): - await _async_unlock( - lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "HELLO"}) - ) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_unlock_service(code="HELLO") + await lock.async_handle_unlock_service(code="1234") assert not lock.is_locked @@ -245,17 +236,11 @@ async def test_lock_with_illegal_code(hass: HomeAssistant) -> None: lock.hass = hass with pytest.raises(ValueError): - await _async_open( - lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_open_service(code="123456") with pytest.raises(ValueError): - await _async_lock( - lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_lock_service(code="123456") with pytest.raises(ValueError): - await _async_unlock( - lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_unlock_service(code="123456") async def test_lock_with_no_code(hass: HomeAssistant) -> None: @@ -265,18 +250,18 @@ async def test_lock_with_no_code(hass: HomeAssistant) -> None: ) lock.hass = hass - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() lock.calls_open.assert_called_with({}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() lock.calls_lock.assert_called_with({}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() lock.calls_unlock.assert_called_with({}) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") lock.calls_open.assert_called_with({}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") lock.calls_lock.assert_called_with({}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") lock.calls_unlock.assert_called_with({}) @@ -292,18 +277,18 @@ async def test_lock_with_default_code(hass: HomeAssistant) -> None: assert lock.state_attributes == {"code_format": r"^\d{4}$"} assert lock._lock_option_default_code == "1234" - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) @@ -316,11 +301,11 @@ async def test_lock_with_provided_and_default_code(hass: HomeAssistant) -> None: ) lock.hass = hass - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "4321"})) + await lock.async_handle_open_service(code="4321") lock.calls_open.assert_called_with({ATTR_CODE: "4321"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "4321"})) + await lock.async_handle_lock_service(code="4321") lock.calls_lock.assert_called_with({ATTR_CODE: "4321"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "4321"})) + await lock.async_handle_unlock_service(code="4321") lock.calls_unlock.assert_called_with({ATTR_CODE: "4321"}) @@ -337,8 +322,8 @@ async def test_lock_with_illegal_default_code(hass: HomeAssistant) -> None: assert lock._lock_option_default_code == "123456" with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() From cd86318b4be20dbee50b9bac5e771fbb8db57fe1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 4 Dec 2023 10:44:29 +0100 Subject: [PATCH 455/643] Do not fail if Reolink ONVIF cannot be connected (#104947) --- homeassistant/components/reolink/host.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f6eb4cb0e55..11cf8f665ad 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -163,7 +163,7 @@ class ReolinkHost: if self._onvif_push_supported: try: await self.subscribe() - except NotSupportedError: + except ReolinkError: self._onvif_push_supported = False self.unregister_webhook() await self._api.unsubscribe() From 63ed4b0769337743ddc870b3feb2347b0fecbd93 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 4 Dec 2023 09:13:27 +0100 Subject: [PATCH 456/643] Bump bimmer-connected to 0.14.6 (#104961) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 1ebf52e52ae..854a2f87410 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.14.5"] + "requirements": ["bimmer-connected[china]==0.14.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index b208d1ca486..2967035208a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -526,7 +526,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.5 +bimmer-connected[china]==0.14.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 892373c2c5f..e5c4936f6f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -448,7 +448,7 @@ beautifulsoup4==4.12.2 bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.5 +bimmer-connected[china]==0.14.6 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 From 204cc20bc2aa7d90ffc4d4aaab302e6a54203a1e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 02:06:01 +0100 Subject: [PATCH 457/643] Do not allow smtp to access insecure files (#104972) --- homeassistant/components/smtp/notify.py | 30 ++++++++++++++++------- tests/components/smtp/test_notify.py | 32 ++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 6b960409305..02a5a6408b6 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -8,6 +8,7 @@ from email.mime.text import MIMEText import email.utils import logging import os +from pathlib import Path import smtplib import voluptuous as vol @@ -193,10 +194,15 @@ class MailNotificationService(BaseNotificationService): if data := kwargs.get(ATTR_DATA): if ATTR_HTML in data: msg = _build_html_msg( - message, data[ATTR_HTML], images=data.get(ATTR_IMAGES, []) + self.hass, + message, + data[ATTR_HTML], + images=data.get(ATTR_IMAGES, []), ) else: - msg = _build_multipart_msg(message, images=data.get(ATTR_IMAGES, [])) + msg = _build_multipart_msg( + self.hass, message, images=data.get(ATTR_IMAGES, []) + ) else: msg = _build_text_msg(message) @@ -241,13 +247,21 @@ def _build_text_msg(message): return MIMEText(message) -def _attach_file(atch_name, content_id=""): +def _attach_file(hass, atch_name, content_id=""): """Create a message attachment. If MIMEImage is successful and content_id is passed (HTML), add images in-line. Otherwise add them as attachments. """ try: + file_path = Path(atch_name).parent + if not hass.config.is_allowed_path(str(file_path)): + _LOGGER.warning( + "'%s' is not secure to load data from, ignoring attachment '%s'!", + file_path, + atch_name, + ) + return with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() except FileNotFoundError: @@ -277,22 +291,22 @@ def _attach_file(atch_name, content_id=""): return attachment -def _build_multipart_msg(message, images): +def _build_multipart_msg(hass, message, images): """Build Multipart message with images as attachments.""" - _LOGGER.debug("Building multipart email with image attachment(s)") + _LOGGER.debug("Building multipart email with image attachme_build_html_msgnt(s)") msg = MIMEMultipart() body_txt = MIMEText(message) msg.attach(body_txt) for atch_name in images: - attachment = _attach_file(atch_name) + attachment = _attach_file(hass, atch_name) if attachment: msg.attach(attachment) return msg -def _build_html_msg(text, html, images): +def _build_html_msg(hass, text, html, images): """Build Multipart message with in-line images and rich HTML (UTF-8).""" _LOGGER.debug("Building HTML rich email") msg = MIMEMultipart("related") @@ -303,7 +317,7 @@ def _build_html_msg(text, html, images): for atch_name in images: name = os.path.basename(atch_name) - attachment = _attach_file(atch_name, name) + attachment = _attach_file(hass, atch_name, name) if attachment: msg.attach(attachment) return msg diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index bca5a5674df..06110a3e5dc 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -1,4 +1,5 @@ """The tests for the notify smtp platform.""" +from pathlib import Path import re from unittest.mock import patch @@ -132,15 +133,44 @@ EMAIL_DATA = [ ], ) def test_send_message( - message_data, data, content_type, hass: HomeAssistant, message + hass: HomeAssistant, message_data, data, content_type, message ) -> None: """Verify if we can send messages of all types correctly.""" sample_email = "" + message.hass = hass + hass.config.allowlist_external_dirs.add(Path("tests/testing_config").resolve()) with patch("email.utils.make_msgid", return_value=sample_email): result, _ = message.send_message(message_data, data=data) assert content_type in result +@pytest.mark.parametrize( + ("message_data", "data", "content_type"), + [ + ( + "Test msg", + {"images": ["tests/testing_config/notify/test.jpg"]}, + "Content-Type: multipart/mixed", + ), + ], +) +def test_sending_insecure_files_fails( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + message_data, + data, + content_type, + message, +) -> None: + """Verify if we cannot send messages with insecure attachments.""" + sample_email = "" + message.hass = hass + with patch("email.utils.make_msgid", return_value=sample_email): + result, _ = message.send_message(message_data, data=data) + assert content_type in result + assert "test.jpg' is not secure to load data from, ignoring attachment" + + def test_send_text_message(hass: HomeAssistant, message) -> None: """Verify if we can send simple text message.""" expected = ( From 64f7855b9430cb2395373229a9b272f0f3cb504c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 4 Dec 2023 11:48:29 +0100 Subject: [PATCH 458/643] Raise on smtp notification if attachment is not allowed (#104981) * Raise smtp notification if attachment not allowed * Pass url as placeholder * Use variable in err message * Add allow_list as placeholder --- homeassistant/components/smtp/notify.py | 26 +++++++++++++++++----- homeassistant/components/smtp/strings.json | 5 +++++ tests/components/smtp/test_notify.py | 17 ++++++++++---- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 02a5a6408b6..dcc2f49db0f 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -32,6 +32,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -255,13 +256,26 @@ def _attach_file(hass, atch_name, content_id=""): """ try: file_path = Path(atch_name).parent - if not hass.config.is_allowed_path(str(file_path)): - _LOGGER.warning( - "'%s' is not secure to load data from, ignoring attachment '%s'!", - file_path, - atch_name, + if os.path.exists(file_path) and not hass.config.is_allowed_path( + str(file_path) + ): + allow_list = "allowlist_external_dirs" + file_name = os.path.basename(atch_name) + url = "https://www.home-assistant.io/docs/configuration/basic/" + raise ServiceValidationError( + f"Cannot send email with attachment '{file_name} " + f"from directory '{file_path} which is not secure to load data from. " + f"Only folders added to `{allow_list}` are accessible. " + f"See {url} for more information.", + translation_domain=DOMAIN, + translation_key="remote_path_not_allowed", + translation_placeholders={ + "allow_list": allow_list, + "file_path": file_path, + "file_name": file_name, + "url": url, + }, ) - return with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() except FileNotFoundError: diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index b711c2f2009..38dd81ac196 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -4,5 +4,10 @@ "name": "[%key:common::action::reload%]", "description": "Reloads smtp notify services." } + }, + "exceptions": { + "remote_path_not_allowed": { + "message": "Cannot send email with attachment '{file_name} form directory '{file_path} which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." + } } } diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 06110a3e5dc..182b45d9c1b 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -11,6 +11,7 @@ from homeassistant.components.smtp.const import DOMAIN from homeassistant.components.smtp.notify import MailNotificationService from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -111,7 +112,7 @@ EMAIL_DATA = [ ), ( "Test msg", - {"html": HTML, "images": ["test.jpg"]}, + {"html": HTML, "images": ["tests/testing_config/notify/test_not_exists.jpg"]}, "Content-Type: multipart/related", ), ( @@ -156,7 +157,6 @@ def test_send_message( ) def test_sending_insecure_files_fails( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, message_data, data, content_type, @@ -165,10 +165,19 @@ def test_sending_insecure_files_fails( """Verify if we cannot send messages with insecure attachments.""" sample_email = "" message.hass = hass - with patch("email.utils.make_msgid", return_value=sample_email): + with patch("email.utils.make_msgid", return_value=sample_email), pytest.raises( + ServiceValidationError + ) as exc: result, _ = message.send_message(message_data, data=data) assert content_type in result - assert "test.jpg' is not secure to load data from, ignoring attachment" + assert exc.value.translation_key == "remote_path_not_allowed" + assert exc.value.translation_domain == DOMAIN + assert ( + str(exc.value.translation_placeholders["file_path"]) + == "tests/testing_config/notify" + ) + assert exc.value.translation_placeholders["url"] + assert exc.value.translation_placeholders["file_name"] == "test.jpg" def test_send_text_message(hass: HomeAssistant, message) -> None: From df8f462370de29dda8e8ac2d03af01781f77baf1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 4 Dec 2023 13:10:51 +0100 Subject: [PATCH 459/643] Update frontend to 20231204.0 (#104990) --- 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 b6668383b54..e254eda0689 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==20231130.0"] + "requirements": ["home-assistant-frontend==20231204.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7dad258068d..4f998e7a663 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2967035208a..3b916366478 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5c4936f6f3..d2194cd6e79 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231130.0 +home-assistant-frontend==20231204.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From 8fd2e6451a0e44e0c56c6fd55571680042d66271 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 4 Dec 2023 14:48:56 +0100 Subject: [PATCH 460/643] W-Z: add host field description (#104996) --- homeassistant/components/weatherflow/strings.json | 4 +++- homeassistant/components/webostv/strings.json | 4 +++- homeassistant/components/wled/strings.json | 3 +++ homeassistant/components/yamaha_musiccast/strings.json | 3 +++ homeassistant/components/yardian/strings.json | 3 +++ homeassistant/components/yeelight/strings.json | 3 +++ homeassistant/components/youless/strings.json | 3 +++ homeassistant/components/zeversolar/strings.json | 3 +++ 8 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index 8f7a98abe04..d075ee34a05 100644 --- a/homeassistant/components/weatherflow/strings.json +++ b/homeassistant/components/weatherflow/strings.json @@ -2,10 +2,12 @@ "config": { "step": { "user": { - "title": "WeatherFlow discovery", "description": "Unable to discover Tempest WeatherFlow devices. Click submit to try again.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tempest WeatherFlow device." } } }, diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index a5e7b73e59e..1d045d48ba5 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -3,11 +3,13 @@ "flow_title": "LG webOS Smart TV", "step": { "user": { - "title": "Connect to webOS TV", "description": "Turn on TV, fill the following fields click submit", "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your webOS TV." } }, "pairing": { diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 61b9cc450fe..eff6dfab572 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -6,6 +6,9 @@ "description": "Set up your WLED to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your WLED device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index c4f28fc750b..d0ee6c030a6 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -6,6 +6,9 @@ "description": "Set up MusicCast to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yamaha MusicCast receiver." } }, "confirm": { diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index f841f3d3ed1..fcaef65ee3e 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yardian Smart Sprinkler Controller. You can find it in the Yardian app." } } }, diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index ab22f42dae3..72baec52c85 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yeelight Wi-Fi bulb." } }, "pick_device": { diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index 563e6834ddd..e0eddd7d137 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your YouLess device." } } }, diff --git a/homeassistant/components/zeversolar/strings.json b/homeassistant/components/zeversolar/strings.json index 0e2e23f244c..b75bbe781ef 100644 --- a/homeassistant/components/zeversolar/strings.json +++ b/homeassistant/components/zeversolar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Zeversolar inverter." } } }, From ca147060d999f4e041712b57cc13e2bd5c7b6480 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 4 Dec 2023 15:00:20 +0100 Subject: [PATCH 461/643] Bump version to 2023.12.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9e1fda9865a..b8ce579ffe8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index c5a1e2705d4..2d5333697bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b1" +version = "2023.12.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 516966db332a843f257f727453d677efc14f6b27 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 4 Dec 2023 17:21:41 +0100 Subject: [PATCH 462/643] Add Matter custom cluster sensors (Eve Energy Plug energy measurements) (#104830) * Support for sensors from custom clusters in Matter * lint * no need to write state twice * Add test for eve energy plug * Update homeassistant/components/matter/entity.py Co-authored-by: Martin Hjelmare * adjust comment * debounce extra poll timer * use async_call_later helper * Update homeassistant/components/matter/entity.py Co-authored-by: Martin Hjelmare * wip extend test * Update test_sensor.py * fix state class for sensors * trigger (fake) event callback on all subscribers * Update eve-energy-plug.json * add test for additionally polled value * adjust delay to 3 seconds * Adjust subscribe_events to always use kwargs * Update tests/components/matter/common.py Co-authored-by: Martin Hjelmare * Update test_sensor.py * remove redundant code --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/adapter.py | 9 +- homeassistant/components/matter/discovery.py | 5 +- homeassistant/components/matter/entity.py | 42 +- homeassistant/components/matter/models.py | 6 + homeassistant/components/matter/sensor.py | 74 +- tests/components/matter/common.py | 8 +- .../fixtures/nodes/eve-energy-plug.json | 649 ++++++++++++++++++ tests/components/matter/test_adapter.py | 7 +- tests/components/matter/test_sensor.py | 82 ++- 9 files changed, 867 insertions(+), 15 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/eve-energy-plug.json diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 2831ebe9a38..5690996841d 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -97,22 +97,23 @@ class MatterAdapter: self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_added_callback, EventType.ENDPOINT_ADDED + callback=endpoint_added_callback, event_filter=EventType.ENDPOINT_ADDED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_removed_callback, EventType.ENDPOINT_REMOVED + callback=endpoint_removed_callback, + event_filter=EventType.ENDPOINT_REMOVED, ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_removed_callback, EventType.NODE_REMOVED + callback=node_removed_callback, event_filter=EventType.NODE_REMOVED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_added_callback, EventType.NODE_ADDED + callback=node_added_callback, event_filter=EventType.NODE_ADDED ) ) diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index c971bf8465e..e1d004a15c8 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -115,8 +115,9 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, + should_poll=schema.should_poll, ) - # prevent re-discovery of the same attributes + # prevent re-discovery of the primary attribute if not allowed if not schema.allow_multi: - discovered_attributes.update(attributes_to_watch) + discovered_attributes.update(schema.required_attributes) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 7e7b7a688df..de6e6ff83c2 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -5,6 +5,7 @@ from abc import abstractmethod from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass +from datetime import datetime import logging from typing import TYPE_CHECKING, Any, cast @@ -12,9 +13,10 @@ from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.event import async_call_later from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -27,6 +29,13 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +# For some manually polled values (e.g. custom clusters) we perform +# an additional poll as soon as a secondary value changes. +# For example update the energy consumption meter when a relay is toggled +# of an energy metering powerplug. The below constant defined the delay after +# which we poll the primary value (debounced). +EXTRA_POLL_DELAY = 3.0 + @dataclass class MatterEntityDescription(EntityDescription): @@ -39,7 +48,6 @@ class MatterEntityDescription(EntityDescription): class MatterEntity(Entity): """Entity class for Matter devices.""" - _attr_should_poll = False _attr_has_entity_name = True def __init__( @@ -71,6 +79,8 @@ class MatterEntity(Entity): identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} ) self._attr_available = self._endpoint.node.available + self._attr_should_poll = entity_info.should_poll + self._extra_poll_timer_unsub: CALLBACK_TYPE | None = None async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -110,15 +120,35 @@ class MatterEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() for unsub in self._unsubscribes: with suppress(ValueError): # suppress ValueError to prevent race conditions unsub() + async def async_update(self) -> None: + """Call when the entity needs to be updated.""" + # manually poll/refresh the primary value + await self.matter_client.refresh_attribute( + self._endpoint.node.node_id, + self.get_matter_attribute_path(self._entity_info.primary_attribute), + ) + self._update_from_device() + @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: - """Call on update.""" + """Call on update from the device.""" self._attr_available = self._endpoint.node.available + if self._attr_should_poll: + # secondary attribute updated of a polled primary value + # enforce poll of the primary value a few seconds later + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() + self._extra_poll_timer_unsub = async_call_later( + self.hass, EXTRA_POLL_DELAY, self._do_extra_poll + ) + return self._update_from_device() self.async_write_ha_state() @@ -145,3 +175,9 @@ class MatterEntity(Entity): return create_attribute_path( self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id ) + + @callback + def _do_extra_poll(self, called_at: datetime) -> None: + """Perform (extra) poll of primary value.""" + # scheduling the regulat update is enough to perform a poll/refresh + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 34447751797..5f47f73b139 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -50,6 +50,9 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type + # [optional] bool to specify if this primary value should be polled + should_poll: bool + @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -106,3 +109,6 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False + + # [optional] bool to specify if this primary value should be polled + should_poll: bool = False diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 5021ed7fa0d..6262eb253aa 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue +from matter_server.client.models.clusters import EveEnergyCluster from homeassistant.components.sensor import ( SensorDeviceClass, @@ -18,6 +19,10 @@ from homeassistant.const import ( PERCENTAGE, EntityCategory, Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, UnitOfPressure, UnitOfTemperature, UnitOfVolumeFlowRate, @@ -48,7 +53,6 @@ class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescrip class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" - _attr_state_class = SensorStateClass.MEASUREMENT entity_description: MatterSensorEntityDescription @callback @@ -72,6 +76,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), @@ -83,6 +88,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), @@ -94,6 +100,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, translation_key="flow", measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), @@ -105,6 +112,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=( @@ -118,6 +126,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), @@ -131,8 +140,71 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, # value has double precision measurement_to_ha=lambda x: int(x / 2), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWatt", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Watt,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorVoltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Voltage,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattAccumulated", + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=3, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.WattAccumulated,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattCurrent", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Current,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), ] diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index a0935154054..d5093367db5 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -71,6 +71,10 @@ async def trigger_subscription_callback( data: Any = None, ) -> None: """Trigger a subscription callback.""" - callback = client.subscribe_events.call_args.kwargs["callback"] - callback(event, data) + # trigger callback on all subscribers + for sub in client.subscribe_events.call_args_list: + callback = sub.kwargs["callback"] + event_filter = sub.kwargs.get("event_filter") + if event_filter in (None, event): + callback(event, data) await hass.async_block_till_done() diff --git a/tests/components/matter/fixtures/nodes/eve-energy-plug.json b/tests/components/matter/fixtures/nodes/eve-energy-plug.json new file mode 100644 index 00000000000..03ff4ce7dba --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eve-energy-plug.json @@ -0,0 +1,649 @@ +{ + "node_id": 83, + "date_commissioned": "2023-11-30T14:39:37.020026", + "last_interview": "2023-11-30T14:39:37.020029", + "interview_version": 5, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 5 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Eve Systems", + "0/40/2": 4874, + "0/40/3": "Eve Energy Plug", + "0/40/4": 80, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 1, + "0/40/8": "1.1", + "0/40/9": 6650, + "0/40/10": "3.2.1", + "0/40/15": "RV44L1A00081", + "0/40/18": "26E8F90561D17C42", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [ + { + "1": 2312386028615903905, + "2": 0, + "254": 1 + } + ], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "cfUKbvsdfsBjT+0=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "cfUKbvBjdsffwT+0=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "ymtKI/b4u+4=", + "5": [], + "6": [ + "/oAAAAA13414AAADIa0oj9vi77g==", + "/XH1Cm71434wAAB8TZpoASmxuw==", + "/RtUBAb134134mAAAPypryIKqshA==" + ], + "7": 4 + } + ], + "0/51/1": 95, + "0/51/2": 268574, + "0/51/3": 4406, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome23", + "0/53/3": 14707, + "0/53/4": 8211480967175688173, + "0/53/5": "QP1x9Qfwefu8AAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 13418684826835773064, + "1": 9, + "2": 3072, + "3": 56455, + "4": 84272, + "5": 1, + "6": -89, + "7": -88, + "8": 16, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3054316089463545304, + "1": 2, + "2": 12288, + "3": 17170, + "4": 58113, + "5": 3, + "6": -45, + "7": -46, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3650476115380598997, + "1": 13, + "2": 15360, + "3": 172475, + "4": 65759, + "5": 3, + "6": -17, + "7": -18, + "8": 12, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 11968039652259981925, + "1": 21, + "2": 21504, + "3": 127929, + "4": 55363, + "5": 3, + "6": -74, + "7": -72, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17156405262946673420, + "1": 22, + "2": 22528, + "3": 22063, + "4": 137698, + "5": 1, + "6": -92, + "7": -92, + "8": 34, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17782243871947087975, + "1": 18, + "2": 23552, + "3": 157044, + "4": 122272, + "5": 2, + "6": -81, + "7": -82, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8276316979900166010, + "1": 17, + "2": 31744, + "3": 486113, + "4": 298427, + "5": 2, + "6": -83, + "7": -82, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 9121696247933828996, + "1": 48, + "2": 53248, + "3": 651530, + "4": 161559, + "5": 3, + "6": -70, + "7": -71, + "8": 15, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 13418684826835773064, + "1": 3072, + "2": 3, + "3": 15, + "4": 1, + "5": 1, + "6": 1, + "7": 9, + "8": true, + "9": true + }, + { + "0": 0, + "1": 7168, + "2": 7, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 76, + "8": true, + "9": false + }, + { + "0": 0, + "1": 10240, + "2": 10, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 243, + "8": true, + "9": false + }, + { + "0": 3054316089463545304, + "1": 12288, + "2": 12, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 2, + "8": true, + "9": true + }, + { + "0": 3650476115380598997, + "1": 15360, + "2": 15, + "3": 12, + "4": 1, + "5": 3, + "6": 3, + "7": 14, + "8": true, + "9": true + }, + { + "0": 11968039652259981925, + "1": 21504, + "2": 21, + "3": 15, + "4": 1, + "5": 3, + "6": 2, + "7": 22, + "8": true, + "9": true + }, + { + "0": 17156405262946673420, + "1": 22528, + "2": 22, + "3": 52, + "4": 1, + "5": 1, + "6": 0, + "7": 23, + "8": true, + "9": true + }, + { + "0": 17782243871947087975, + "1": 23552, + "2": 23, + "3": 15, + "4": 1, + "5": 2, + "6": 2, + "7": 19, + "8": true, + "9": true + }, + { + "0": 0, + "1": 29696, + "2": 29, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 8276316979900166010, + "1": 31744, + "2": 31, + "3": 52, + "4": 1, + "5": 2, + "6": 2, + "7": 18, + "8": true, + "9": true + }, + { + "0": 0, + "1": 39936, + "2": 39, + "3": 52, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 9121696247933828996, + "1": 53248, + "2": 52, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 48, + "8": true, + "9": true + }, + { + "0": 14585833336497290222, + "1": 54272, + "2": 53, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + } + ], + "0/53/9": 1828774034, + "0/53/10": 68, + "0/53/11": 237, + "0/53/12": 170, + "0/53/13": 23, + "0/53/14": 2, + "0/53/15": 1, + "0/53/16": 2, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 2, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 293884, + "0/53/23": 278934, + "0/53/24": 14950, + "0/53/25": 278894, + "0/53/26": 278468, + "0/53/27": 14990, + "0/53/28": 293844, + "0/53/29": 0, + "0/53/30": 40, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 65244, + "0/53/34": 426, + "0/53/35": 0, + "0/53/36": 87, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 6687540, + "0/53/40": 142626, + "0/53/41": 106835, + "0/53/42": 246171, + "0/53/43": 0, + "0/53/44": 541, + "0/53/45": 40, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 6360718, + "0/53/49": 2141, + "0/53/50": 35259, + "0/53/51": 4374, + "0/53/52": 0, + "0/53/53": 568, + "0/53/54": 18599, + "0/53/55": 19143, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [0, 0, 0, 0], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRUxgkBwEkCAEwCUEEg58CF25hrI1R598dXwRapPCYUjahad5XkJMrA0tZb8HXO67XlyD4L+1ljtb6IAHhxjOGew2jNVSQDH1aqRGsODcKNQEoARgkAgE2AwQCBAEYMAQUkpBmmh0G57MnnxYDgxZuAZBezjYwBRTphWiJ/NqGe3Cx3Nj8H02NgGioSRgwC0CCOOCnKlhpegJmaH8vSIO38MQcJq+qV85UPPqaYc8dakaAnASvYeurP41Jw4KrCqyLMNRhUwqeyKoql6iQFKNAGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEYztrLK2UY1ORHUEFLO7PDfVjw/MnMDNX5kjdHHDU7npeITnSyg/kxxUM+pD7ccxfDuHQKHbBq9+qbJi8oGik8DcKNQEpARgkAmAwBBTphWiJ/NqGe3Cx3Nj8H02NgGioSTAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQOOcZAL8XEktvE5sjrUmFNhkP2g3Ef+4BHtogItdZYyA9E/WbzW25E0UxZInwjjIzH3YimDUZVoEWGML8NV2kCEY", + "254": 5 + } + ], + "0/62/1": [ + { + "1": "BIbR4Iu8CNIdxKRkSjTb1LKY3nzCbFVwDrjkRe4WDorCiMZHJmypZW24wBgAHxNo8D00QWw29llu8FH1eOtmHIo=", + "2": 4937, + "3": 1, + "4": 3878431683, + "5": "Thuis", + "254": 1 + }, + { + "1": "BLlk4ui4wSQ+xz89jB5nBRQUVYdY9H2dBUawGXVUxa2bsKh2k8CHijv1tkz1dThPXA9UK8jOAZ+7Mi+y7BPuAcg=", + "2": 4996, + "3": 2, + "4": 3763070728, + "5": "", + "254": 2 + }, + { + "1": "BAg5aeR7RuFKZhukCxMGglCd00dKlhxGq8BbjeyZClKz5kN2Ytzav0xWsiWEEb3s9uvMIYFoQYULnSJvOMTcD14=", + "2": 65521, + "3": 1, + "4": 83, + "5": "", + "254": 5 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AycUxofpv3kE1HwkFQEYJgS2Ty8rJgU2gxAtNwYnFMaH6b95BNR8JBUBGCQHASQIATAJQQSG0eCLvAjSHcSkZEo029SymN58wmxVcA645EXuFg6KwojGRyZsqWVtuMAYAB8TaPA9NEFsNvZZbvBR9XjrZhyKNwo1ASkBGCQCYDAEFNnFRJ+9qQIJtsM+LRdMdmCY3bQ4MAUU2cVEn72pAgm2wz4tF0x2YJjdtDgYMAtAFDv6Ouh7ugAGLiCjBQaEXCIAe0AkaaN8dBPskCZXOODjuZ1DCr4/f5IYg0rN2zFDUDTvG3GCxoI1+A7BvSjiNRg=", + "FTABAQAkAgE3AycUjuqR8vTQCmEkFQIYJgTFTy8rJgVFgxAtNwYnFI7qkfL00AphJBUCGCQHASQIATAJQQS5ZOLouMEkPsc/PYweZwUUFFWHWPR9nQVGsBl1VMWtm7CodpPAh4o79bZM9XU4T1wPVCvIzgGfuzIvsuwT7gHINwo1ASkBGCQCYDAEFKEEplpzAvCzsc5ga6CFmqmsv5onMAUUoQSmWnMC8LOxzmBroIWaqay/micYMAtAYkkA8OZFIGpxBEYYT+3A7Okba4WOq4NtwctIIZvCM48VU8pxQNjVvHMcJWPOP1Wh2Bw1VH7/Sg9lt9DL4DAwjBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEECDlp5HtG4UpmG6QLEwaCUJ3TR0qWHEarwFuN7JkKUrPmQ3Zi3Nq/TFayJYQRvez268whgWhBhQudIm84xNwPXjcKNQEpARgkAmAwBBTJ3+WZAQkWgZboUpiyZL3FV8R8UzAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQO9QSAdvJkM6b/wIc07MCw1ma46lTyGYG8nvpn0ICI73nuD3QeaWwGIQTkVGEpzF+TuDK7gtTz7YUrR+PSnvMk8Y" + ], + "0/62/5": 5, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 266, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 29, 319486977], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/319486977/319422464": "AAFQCwIAAAMC+xkEDFJWNDRMMUEwMDA4MZwBAP8EAQIA1PkBAWABZNAEAAAAAEUFBQAAAABGCQUAAAAOAABCBkkGBQwIEIABRBEFFAAFAzwAAAAAAAAAAAAAAEcRBSoh/CGWImgjeAAAADwAAABIBgUAAAAAAEoGBQAAAAAA", + "1/319486977/319422466": "BEZiAQAAAAAAAAAABgsCDAINAgcCDgEBAn4PABAAWgAAs8c+AQEA", + "1/319486977/319422467": "EgtaAAB74T4BDwAANwkAAAAA", + "1/319486977/319422471": 0, + "1/319486977/319422472": 238.8000030517578, + "1/319486977/319422473": 0.0, + "1/319486977/319422474": 0.0, + "1/319486977/319422475": 0.2200000286102295, + "1/319486977/319422476": 0, + "1/319486977/319422478": 0, + "1/319486977/319422481": false, + "1/319486977/319422482": 54272, + "1/319486977/65533": 1, + "1/319486977/65528": [], + "1/319486977/65529": [], + "1/319486977/65531": [ + 65528, 65529, 65531, 319422464, 319422465, 319422466, 319422467, + 319422468, 319422469, 319422471, 319422472, 319422473, 319422474, + 319422475, 319422476, 319422478, 319422481, 319422482, 65533 + ] + }, + "attribute_subscriptions": [], + "last_subscription_attempt": 0 +} diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 8ed309f61df..35e6673114e 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -145,9 +145,12 @@ async def test_node_added_subscription( ) -> None: """Test subscription to new devices work.""" assert matter_client.subscribe_events.call_count == 4 - assert matter_client.subscribe_events.call_args[0][1] == EventType.NODE_ADDED + assert ( + matter_client.subscribe_events.call_args.kwargs["event_filter"] + == EventType.NODE_ADDED + ) - node_added_callback = matter_client.subscribe_events.call_args[0][0] + node_added_callback = matter_client.subscribe_events.call_args.kwargs["callback"] node_data = load_and_parse_node_fixture("onoff-light") node = MatterNode( dataclass_from_dict( diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 0d8f892f992..5b343b8c4e5 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -1,5 +1,6 @@ """Test Matter sensors.""" -from unittest.mock import MagicMock +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode import pytest @@ -14,6 +15,8 @@ from .common import ( trigger_subscription_callback, ) +from tests.common import async_fire_time_changed + @pytest.fixture(name="flow_sensor_node") async def flow_sensor_node_fixture( @@ -63,6 +66,16 @@ async def temperature_sensor_node_fixture( ) +@pytest.fixture(name="eve_energy_plug_node") +async def eve_energy_plug_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Eve Energy Plug node.""" + return await setup_integration_with_node_fixture( + hass, "eve-energy-plug", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( @@ -208,3 +221,70 @@ async def test_battery_sensor( assert entry assert entry.entity_category == EntityCategory.DIAGNOSTIC + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_eve_energy_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + eve_energy_plug_node: MatterNode, +) -> None: + """Test Energy sensors created from Eve Energy custom cluster.""" + # power sensor + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "W" + assert state.attributes["device_class"] == "power" + assert state.attributes["friendly_name"] == "Eve Energy Plug Power" + + # voltage sensor + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "238.800003051758" + assert state.attributes["unit_of_measurement"] == "V" + assert state.attributes["device_class"] == "voltage" + assert state.attributes["friendly_name"] == "Eve Energy Plug Voltage" + + # energy sensor + entity_id = "sensor.eve_energy_plug_energy" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.220000028610229" + assert state.attributes["unit_of_measurement"] == "kWh" + assert state.attributes["device_class"] == "energy" + assert state.attributes["friendly_name"] == "Eve Energy Plug Energy" + assert state.attributes["state_class"] == "total_increasing" + + # current sensor + entity_id = "sensor.eve_energy_plug_current" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "A" + assert state.attributes["device_class"] == "current" + assert state.attributes["friendly_name"] == "Eve Energy Plug Current" + + # test if the sensor gets polled on interval + eve_energy_plug_node.update_attribute("1/319486977/319422472", 237.0) + async_fire_time_changed(hass, datetime.now(UTC) + timedelta(seconds=31)) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "237.0" + + # test extra poll triggered when secondary value (switch state) changes + set_node_attribute(eve_energy_plug_node, 1, 6, 0, True) + eve_energy_plug_node.update_attribute("1/319486977/319422474", 5.0) + with patch("homeassistant.components.matter.entity.EXTRA_POLL_DELAY", 0.0): + await trigger_subscription_callback(hass, matter_client) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "5.0" From 35e2f591c194c81695d809437d3b9a9e5c3ece2d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Dec 2023 20:36:16 +0100 Subject: [PATCH 463/643] Make `cv.string` return subclasses of str as is (#103916) --- homeassistant/helpers/config_validation.py | 7 ++++++- tests/helpers/test_config_validation.py | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 18445ba0789..e07596ad450 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -99,6 +99,7 @@ from homeassistant.generated.countries import COUNTRIES from homeassistant.generated.languages import LANGUAGES from homeassistant.util import raise_if_invalid_path, slugify as util_slugify import homeassistant.util.dt as dt_util +from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper @@ -581,7 +582,11 @@ def string(value: Any) -> str: raise vol.Invalid("string value is None") # This is expected to be the most common case, so check it first. - if type(value) is str: # noqa: E721 + if ( + type(value) is str # noqa: E721 + or type(value) is NodeStrClass # noqa: E721 + or isinstance(value, str) + ): return value if isinstance(value, template_helper.ResultWrapper): diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 6d1945f2d5f..b44137e4f5c 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -539,6 +539,13 @@ def test_string(hass: HomeAssistant) -> None: for value in (True, 1, "hello"): schema(value) + # Test subclasses of str are returned + class MyString(str): + pass + + my_string = MyString("hello") + assert schema(my_string) is my_string + # Test template support for text, native in ( ("[1, 2]", [1, 2]), From 677c50a7cc58e9caf24cd5ab117adeb9900431d3 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Mon, 4 Dec 2023 11:37:09 -0800 Subject: [PATCH 464/643] Exclude Todoist sub-tasks for the todo platform (#104914) --- homeassistant/components/todoist/todo.py | 3 +++ tests/components/todoist/conftest.py | 3 ++- tests/components/todoist/test_todo.py | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 64e83b8cc6e..6231a6878ae 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -85,6 +85,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit for task in self.coordinator.data: if task.project_id != self._project_id: continue + if task.parent_id is not None: + # Filter out sub-tasks until they are supported by the UI. + continue if task.is_completed: status = TodoItemStatus.COMPLETED else: diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 4e4d41b6914..42251b0ea18 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -46,6 +46,7 @@ def make_api_task( due: Due | None = None, project_id: str | None = None, description: str | None = None, + parent_id: str | None = None, ) -> Task: """Mock a todoist Task instance.""" return Task( @@ -61,7 +62,7 @@ def make_api_task( id=id or "1", labels=["Label1"], order=1, - parent_id=None, + parent_id=parent_id, priority=1, project_id=project_id or PROJECT_ID, section_id=None, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index aa00e2c2ff4..1e94b52149c 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -51,6 +51,14 @@ def set_time_zone(hass: HomeAssistant) -> None: ], "0", ), + ( + [ + make_api_task( + id="12345", content="sub-task", is_completed=False, parent_id="1" + ) + ], + "0", + ), ], ) async def test_todo_item_state( From a9381d259040d4a02881cc9e203bc8e3946b2146 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 4 Dec 2023 14:13:15 -0600 Subject: [PATCH 465/643] Add Wyoming satellite (#104759) * First draft of Wyoming satellite * Set up homeassistant in tests * Move satellite * Add devices with binary sensor and select * Add more events * Add satellite enabled switch * Fix mistake * Only set up necessary platforms for satellites * Lots of fixes * Add tests * Use config entry id as satellite id * Initial satellite test * Add satellite pipeline test * More tests * More satellite tests * Only support single device per config entry * Address comments * Make a copy of platforms --- homeassistant/components/wyoming/__init__.py | 77 ++- .../components/wyoming/binary_sensor.py | 55 +++ .../components/wyoming/config_flow.py | 91 +++- homeassistant/components/wyoming/data.py | 39 +- homeassistant/components/wyoming/devices.py | 85 ++++ homeassistant/components/wyoming/entity.py | 24 + .../components/wyoming/manifest.json | 4 +- homeassistant/components/wyoming/models.py | 13 + homeassistant/components/wyoming/satellite.py | 380 +++++++++++++++ homeassistant/components/wyoming/select.py | 47 ++ homeassistant/components/wyoming/strings.json | 30 +- homeassistant/components/wyoming/stt.py | 5 +- homeassistant/components/wyoming/switch.py | 65 +++ homeassistant/components/wyoming/tts.py | 5 +- homeassistant/components/wyoming/wake_word.py | 5 +- homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wyoming/__init__.py | 28 +- tests/components/wyoming/conftest.py | 47 +- .../wyoming/snapshots/test_config_flow.ambr | 42 ++ .../components/wyoming/test_binary_sensor.py | 34 ++ tests/components/wyoming/test_config_flow.py | 81 ++- tests/components/wyoming/test_data.py | 43 +- tests/components/wyoming/test_devices.py | 78 +++ tests/components/wyoming/test_satellite.py | 460 ++++++++++++++++++ tests/components/wyoming/test_select.py | 83 ++++ tests/components/wyoming/test_switch.py | 32 ++ 28 files changed, 1802 insertions(+), 60 deletions(-) create mode 100644 homeassistant/components/wyoming/binary_sensor.py create mode 100644 homeassistant/components/wyoming/devices.py create mode 100644 homeassistant/components/wyoming/entity.py create mode 100644 homeassistant/components/wyoming/models.py create mode 100644 homeassistant/components/wyoming/satellite.py create mode 100644 homeassistant/components/wyoming/select.py create mode 100644 homeassistant/components/wyoming/switch.py create mode 100644 tests/components/wyoming/test_binary_sensor.py create mode 100644 tests/components/wyoming/test_devices.py create mode 100644 tests/components/wyoming/test_satellite.py create mode 100644 tests/components/wyoming/test_select.py create mode 100644 tests/components/wyoming/test_switch.py diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 33064d21097..2cc9b7050a0 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -4,17 +4,26 @@ from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService +from .devices import SatelliteDevice +from .models import DomainDataItem +from .satellite import WyomingSatellite _LOGGER = logging.getLogger(__name__) +SATELLITE_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SWITCH] + __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup_entry", + "async_unload_entry", ] @@ -25,24 +34,72 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if service is None: raise ConfigEntryNotReady("Unable to connect") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = service + item = DomainDataItem(service=service) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = item - await hass.config_entries.async_forward_entry_setups( - entry, - service.platforms, - ) + await hass.config_entries.async_forward_entry_setups(entry, service.platforms) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + if (satellite_info := service.info.satellite) is not None: + # Create satellite device, etc. + item.satellite = _make_satellite(hass, entry, service) + + # Set up satellite sensors, switches, etc. + await hass.config_entries.async_forward_entry_setups(entry, SATELLITE_PLATFORMS) + + # Start satellite communication + entry.async_create_background_task( + hass, + item.satellite.run(), + f"Satellite {satellite_info.name}", + ) + + entry.async_on_unload(item.satellite.stop) return True +def _make_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService +) -> WyomingSatellite: + """Create Wyoming satellite/device from config entry and Wyoming service.""" + satellite_info = service.info.satellite + assert satellite_info is not None + + dev_reg = dr.async_get(hass) + + # Use config entry id since only one satellite per entry is supported + satellite_id = config_entry.entry_id + + device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, satellite_id)}, + name=satellite_info.name, + suggested_area=satellite_info.area, + ) + + satellite_device = SatelliteDevice( + satellite_id=satellite_id, + device_id=device.id, + ) + + return WyomingSatellite(hass, service, satellite_device) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Wyoming.""" - service: WyomingService = hass.data[DOMAIN][entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms( - entry, - service.platforms, - ) + platforms = list(item.service.platforms) + if item.satellite is not None: + platforms += SATELLITE_PLATFORMS + + unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) if unload_ok: del hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py new file mode 100644 index 00000000000..4f2c0bb170a --- /dev/null +++ b/homeassistant/components/wyoming/binary_sensor.py @@ -0,0 +1,55 @@ +"""Binary sensor for Wyoming.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteAssistInProgress(item.satellite.device)]) + + +class WyomingSatelliteAssistInProgress(WyomingSatelliteEntity, BinarySensorEntity): + """Entity to represent Assist is in progress for satellite.""" + + entity_description = BinarySensorEntityDescription( + key="assist_in_progress", + translation_key="assist_in_progress", + ) + _attr_is_on = False + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + self._device.set_is_active_listener(self._is_active_changed) + + @callback + def _is_active_changed(self) -> None: + """Call when active state changed.""" + self._attr_is_on = self._device.is_active + self.async_write_ha_state() diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index f6b8ed73890..b766fc80c89 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -1,19 +1,22 @@ """Config flow for Wyoming integration.""" from __future__ import annotations +import logging from typing import Any from urllib.parse import urlparse import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components import hassio, zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN from .data import WyomingService +_LOGGER = logging.getLogger() + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -27,7 +30,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - _hassio_discovery: HassioServiceInfo + _hassio_discovery: hassio.HassioServiceInfo + _service: WyomingService | None = None + _name: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -50,27 +55,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors={"base": "cannot_connect"}, ) - # ASR = automated speech recognition (speech-to-text) - asr_installed = [asr for asr in service.info.asr if asr.installed] + if name := service.get_name(): + return self.async_create_entry(title=name, data=user_input) - # TTS = text-to-speech - tts_installed = [tts for tts in service.info.tts if tts.installed] + return self.async_abort(reason="no_services") - # wake-word-detection - wake_installed = [wake for wake in service.info.wake if wake.installed] - - if asr_installed: - name = asr_installed[0].name - elif tts_installed: - name = tts_installed[0].name - elif wake_installed: - name = wake_installed[0].name - else: - return self.async_abort(reason="no_services") - - return self.async_create_entry(title=name, data=user_input) - - async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: + async def async_step_hassio( + self, discovery_info: hassio.HassioServiceInfo + ) -> FlowResult: """Handle Supervisor add-on discovery.""" await self.async_set_unique_id(discovery_info.uuid) self._abort_if_unique_id_configured() @@ -93,11 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: uri = urlparse(self._hassio_discovery.config["uri"]) if service := await WyomingService.create(uri.hostname, uri.port): - if ( - not any(asr for asr in service.info.asr if asr.installed) - and not any(tts for tts in service.info.tts if tts.installed) - and not any(wake for wake in service.info.wake if wake.installed) - ): + if not service.has_services(): return self.async_abort(reason="no_services") return self.async_create_entry( @@ -112,3 +100,52 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"addon": self._hassio_discovery.name}, errors=errors, ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Discovery info: %s", discovery_info) + if discovery_info.port is None: + return self.async_abort(reason="no_port") + + service = await WyomingService.create(discovery_info.host, discovery_info.port) + if (service is None) or (not (name := service.get_name())): + # No supported services + return self.async_abort(reason="no_services") + + self._name = name + + # Use zeroconf name + service name as unique id. + # The satellite will use its own MAC as the zeroconf name by default. + unique_id = f"{discovery_info.name}_{self._name}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + self.context[CONF_NAME] = self._name + self.context["title_placeholders"] = {"name": self._name} + + self._service = service + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + assert self._service is not None + assert self._name is not None + + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self._name}, + errors={}, + ) + + return self.async_create_entry( + title=self._name, + data={ + CONF_HOST: self._service.host, + CONF_PORT: self._service.port, + }, + ) diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index 64b92eb8471..ea58181a707 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from wyoming.client import AsyncTcpClient -from wyoming.info import Describe, Info +from wyoming.info import Describe, Info, Satellite from homeassistant.const import Platform @@ -32,6 +32,43 @@ class WyomingService: platforms.append(Platform.WAKE_WORD) self.platforms = platforms + def has_services(self) -> bool: + """Return True if services are installed that Home Assistant can use.""" + return ( + any(asr for asr in self.info.asr if asr.installed) + or any(tts for tts in self.info.tts if tts.installed) + or any(wake for wake in self.info.wake if wake.installed) + or ((self.info.satellite is not None) and self.info.satellite.installed) + ) + + def get_name(self) -> str | None: + """Return name of first installed usable service.""" + # ASR = automated speech recognition (speech-to-text) + asr_installed = [asr for asr in self.info.asr if asr.installed] + if asr_installed: + return asr_installed[0].name + + # TTS = text-to-speech + tts_installed = [tts for tts in self.info.tts if tts.installed] + if tts_installed: + return tts_installed[0].name + + # wake-word-detection + wake_installed = [wake for wake in self.info.wake if wake.installed] + if wake_installed: + return wake_installed[0].name + + # satellite + satellite_installed: Satellite | None = None + + if (self.info.satellite is not None) and self.info.satellite.installed: + satellite_installed = self.info.satellite + + if satellite_installed: + return satellite_installed.name + + return None + @classmethod async def create(cls, host: str, port: int) -> WyomingService | None: """Create a Wyoming service.""" diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py new file mode 100644 index 00000000000..90dad889707 --- /dev/null +++ b/homeassistant/components/wyoming/devices.py @@ -0,0 +1,85 @@ +"""Class to manage satellite devices.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN + + +@dataclass +class SatelliteDevice: + """Class to store device.""" + + satellite_id: str + device_id: str + is_active: bool = False + is_enabled: bool = True + pipeline_name: str | None = None + + _is_active_listener: Callable[[], None] | None = None + _is_enabled_listener: Callable[[], None] | None = None + _pipeline_listener: Callable[[], None] | None = None + + @callback + def set_is_active(self, active: bool) -> None: + """Set active state.""" + if active != self.is_active: + self.is_active = active + if self._is_active_listener is not None: + self._is_active_listener() + + @callback + def set_is_enabled(self, enabled: bool) -> None: + """Set enabled state.""" + if enabled != self.is_enabled: + self.is_enabled = enabled + if self._is_enabled_listener is not None: + self._is_enabled_listener() + + @callback + def set_pipeline_name(self, pipeline_name: str) -> None: + """Inform listeners that pipeline selection has changed.""" + if pipeline_name != self.pipeline_name: + self.pipeline_name = pipeline_name + if self._pipeline_listener is not None: + self._pipeline_listener() + + @callback + def set_is_active_listener(self, is_active_listener: Callable[[], None]) -> None: + """Listen for updates to is_active.""" + self._is_active_listener = is_active_listener + + @callback + def set_is_enabled_listener(self, is_enabled_listener: Callable[[], None]) -> None: + """Listen for updates to is_enabled.""" + self._is_enabled_listener = is_enabled_listener + + @callback + def set_pipeline_listener(self, pipeline_listener: Callable[[], None]) -> None: + """Listen for updates to pipeline.""" + self._pipeline_listener = pipeline_listener + + def get_assist_in_progress_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for assist in progress binary sensor.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "binary_sensor", DOMAIN, f"{self.satellite_id}-assist_in_progress" + ) + + def get_satellite_enabled_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for satellite enabled switch.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "switch", DOMAIN, f"{self.satellite_id}-satellite_enabled" + ) + + def get_pipeline_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for pipeline select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-pipeline" + ) diff --git a/homeassistant/components/wyoming/entity.py b/homeassistant/components/wyoming/entity.py new file mode 100644 index 00000000000..5ed890bc60e --- /dev/null +++ b/homeassistant/components/wyoming/entity.py @@ -0,0 +1,24 @@ +"""Wyoming entities.""" + +from __future__ import annotations + +from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN +from .satellite import SatelliteDevice + + +class WyomingSatelliteEntity(entity.Entity): + """Wyoming satellite entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, device: SatelliteDevice) -> None: + """Initialize entity.""" + self._device = device + self._attr_unique_id = f"{device.satellite_id}-{self.entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.satellite_id)}, + ) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index ddb5407e1ce..540aaa9aeac 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -3,7 +3,9 @@ "name": "Wyoming Protocol", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, + "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.2.0"] + "requirements": ["wyoming==1.3.0"], + "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/models.py b/homeassistant/components/wyoming/models.py new file mode 100644 index 00000000000..dce45d509eb --- /dev/null +++ b/homeassistant/components/wyoming/models.py @@ -0,0 +1,13 @@ +"""Models for wyoming.""" +from dataclasses import dataclass + +from .data import WyomingService +from .satellite import WyomingSatellite + + +@dataclass +class DomainDataItem: + """Domain data item.""" + + service: WyomingService + satellite: WyomingSatellite | None = None diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py new file mode 100644 index 00000000000..caf65db115e --- /dev/null +++ b/homeassistant/components/wyoming/satellite.py @@ -0,0 +1,380 @@ +"""Support for Wyoming satellite services.""" +import asyncio +from collections.abc import AsyncGenerator +import io +import logging +from typing import Final +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize, SynthesizeVoice +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, stt, tts +from homeassistant.components.assist_pipeline import select as pipeline_select +from homeassistant.core import Context, HomeAssistant + +from .const import DOMAIN +from .data import WyomingService +from .devices import SatelliteDevice + +_LOGGER = logging.getLogger() + +_SAMPLES_PER_CHUNK: Final = 1024 +_RECONNECT_SECONDS: Final = 10 +_RESTART_SECONDS: Final = 3 + +# Wyoming stage -> Assist stage +_STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { + PipelineStage.WAKE: assist_pipeline.PipelineStage.WAKE_WORD, + PipelineStage.ASR: assist_pipeline.PipelineStage.STT, + PipelineStage.HANDLE: assist_pipeline.PipelineStage.INTENT, + PipelineStage.TTS: assist_pipeline.PipelineStage.TTS, +} + + +class WyomingSatellite: + """Remove voice satellite running the Wyoming protocol.""" + + def __init__( + self, hass: HomeAssistant, service: WyomingService, device: SatelliteDevice + ) -> None: + """Initialize satellite.""" + self.hass = hass + self.service = service + self.device = device + self.is_enabled = True + self.is_running = True + + self._client: AsyncTcpClient | None = None + self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1) + self._is_pipeline_running = False + self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() + self._pipeline_id: str | None = None + self._enabled_changed_event = asyncio.Event() + + self.device.set_is_enabled_listener(self._enabled_changed) + self.device.set_pipeline_listener(self._pipeline_changed) + + async def run(self) -> None: + """Run and maintain a connection to satellite.""" + _LOGGER.debug("Running satellite task") + + try: + while self.is_running: + try: + # Check if satellite has been disabled + if not self.device.is_enabled: + await self.on_disabled() + if not self.is_running: + # Satellite was stopped while waiting to be enabled + break + + # Connect and run pipeline loop + await self._run_once() + except asyncio.CancelledError: + raise + except Exception: # pylint: disable=broad-exception-caught + await self.on_restart() + finally: + # Ensure sensor is off + self.device.set_is_active(False) + + await self.on_stopped() + + def stop(self) -> None: + """Signal satellite task to stop running.""" + self.is_running = False + + # Unblock waiting for enabled + self._enabled_changed_event.set() + + async def on_restart(self) -> None: + """Block until pipeline loop will be restarted.""" + _LOGGER.warning( + "Unexpected error running satellite. Restarting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RESTART_SECONDS) + + async def on_reconnect(self) -> None: + """Block until a reconnection attempt should be made.""" + _LOGGER.debug( + "Failed to connect to satellite. Reconnecting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RECONNECT_SECONDS) + + async def on_disabled(self) -> None: + """Block until device may be enabled again.""" + await self._enabled_changed_event.wait() + + async def on_stopped(self) -> None: + """Run when run() has fully stopped.""" + _LOGGER.debug("Satellite task stopped") + + # ------------------------------------------------------------------------- + + def _enabled_changed(self) -> None: + """Run when device enabled status changes.""" + + if not self.device.is_enabled: + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + self._enabled_changed_event.set() + + def _pipeline_changed(self) -> None: + """Run when device pipeline changes.""" + + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + async def _run_once(self) -> None: + """Run pipelines until an error occurs.""" + self.device.set_is_active(False) + + while self.is_running and self.is_enabled: + try: + await self._connect() + break + except ConnectionError: + await self.on_reconnect() + + assert self._client is not None + _LOGGER.debug("Connected to satellite") + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled during connection + return + + # Tell satellite that we're ready + await self._client.write_event(RunSatellite().event()) + + # Wait until we get RunPipeline event + run_pipeline: RunPipeline | None = None + while self.is_running and self.is_enabled: + run_event = await self._client.read_event() + if run_event is None: + raise ConnectionResetError("Satellite disconnected") + + if RunPipeline.is_type(run_event.type): + run_pipeline = RunPipeline.from_event(run_event) + break + + _LOGGER.debug("Unexpected event from satellite: %s", run_event) + + assert run_pipeline is not None + _LOGGER.debug("Received run information: %s", run_pipeline) + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled while waiting for + # RunPipeline event. + return + + start_stage = _STAGES.get(run_pipeline.start_stage) + end_stage = _STAGES.get(run_pipeline.end_stage) + + if start_stage is None: + raise ValueError(f"Invalid start stage: {start_stage}") + + if end_stage is None: + raise ValueError(f"Invalid end stage: {end_stage}") + + # Each loop is a pipeline run + while self.is_running and self.is_enabled: + # Use select to get pipeline each time in case it's changed + pipeline_id = pipeline_select.get_chosen_pipeline( + self.hass, + DOMAIN, + self.device.satellite_id, + ) + pipeline = assist_pipeline.async_get_pipeline(self.hass, pipeline_id) + assert pipeline is not None + + # We will push audio in through a queue + self._audio_queue = asyncio.Queue() + stt_stream = self._stt_stream() + + # Start pipeline running + _LOGGER.debug( + "Starting pipeline %s from %s to %s", + pipeline.name, + start_stage, + end_stage, + ) + self._is_pipeline_running = True + _pipeline_task = asyncio.create_task( + assist_pipeline.async_pipeline_from_audio_stream( + self.hass, + context=Context(), + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language=pipeline.language, + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=stt_stream, + start_stage=start_stage, + end_stage=end_stage, + tts_audio_output="wav", + pipeline_id=pipeline_id, + ) + ) + + # Run until pipeline is complete or cancelled with an empty audio chunk + while self._is_pipeline_running: + client_event = await self._client.read_event() + if client_event is None: + raise ConnectionResetError("Satellite disconnected") + + if AudioChunk.is_type(client_event.type): + # Microphone audio + chunk = AudioChunk.from_event(client_event) + chunk = self._chunk_converter.convert(chunk) + self._audio_queue.put_nowait(chunk.audio) + else: + _LOGGER.debug("Unexpected event from satellite: %s", client_event) + + _LOGGER.debug("Pipeline finished") + + def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: + """Translate pipeline events into Wyoming events.""" + assert self._client is not None + + if event.type == assist_pipeline.PipelineEventType.RUN_END: + # Pipeline run is complete + self._is_pipeline_running = False + self.device.set_is_active(False) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: + self.hass.add_job(self._client.write_event(Detect().event())) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END: + # Wake word detection + self.device.set_is_active(True) + + # Inform client of wake word detection + if event.data and (wake_word_output := event.data.get("wake_word_output")): + detection = Detection( + name=wake_word_output["wake_word_id"], + timestamp=wake_word_output.get("timestamp"), + ) + self.hass.add_job(self._client.write_event(detection.event())) + elif event.type == assist_pipeline.PipelineEventType.STT_START: + # Speech-to-text + self.device.set_is_active(True) + + if event.data: + self.hass.add_job( + self._client.write_event( + Transcribe(language=event.data["metadata"]["language"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_START: + # User started speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStarted(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_END: + # User stopped speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStopped(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_END: + # Speech-to-text transcript + if event.data: + # Inform client of transript + stt_text = event.data["stt_output"]["text"] + self.hass.add_job( + self._client.write_event(Transcript(text=stt_text).event()) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_START: + # Text-to-speech text + if event.data: + # Inform client of text + self.hass.add_job( + self._client.write_event( + Synthesize( + text=event.data["tts_input"], + voice=SynthesizeVoice( + name=event.data.get("voice"), + language=event.data.get("language"), + ), + ).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_END: + # TTS stream + if event.data and (tts_output := event.data["tts_output"]): + media_id = tts_output["media_id"] + self.hass.add_job(self._stream_tts(media_id)) + + async def _connect(self) -> None: + """Connect to satellite over TCP.""" + _LOGGER.debug( + "Connecting to satellite at %s:%s", self.service.host, self.service.port + ) + self._client = AsyncTcpClient(self.service.host, self.service.port) + await self._client.connect() + + async def _stream_tts(self, media_id: str) -> None: + """Stream TTS WAV audio to satellite in chunks.""" + assert self._client is not None + + extension, data = await tts.async_get_media_source_audio(self.hass, media_id) + if extension != "wav": + raise ValueError(f"Cannot stream audio format to satellite: {extension}") + + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + + timestamp = 0 + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() + ) + + # Stream audio chunks + while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): + chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=audio_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + timestamp += chunk.seconds + + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") + + async def _stt_stream(self) -> AsyncGenerator[bytes, None]: + """Yield audio chunks from a queue.""" + is_first_chunk = True + while chunk := await self._audio_queue.get(): + if is_first_chunk: + is_first_chunk = False + _LOGGER.debug("Receiving audio from satellite") + + yield chunk diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py new file mode 100644 index 00000000000..2929ae79fa0 --- /dev/null +++ b/homeassistant/components/wyoming/select.py @@ -0,0 +1,47 @@ +"""Select entities for VoIP integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import SatelliteDevice +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatellitePipelineSelect(hass, item.satellite.device)]) + + +class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelect): + """Pipeline selector for Wyoming satellites.""" + + def __init__(self, hass: HomeAssistant, device: SatelliteDevice) -> None: + """Initialize a pipeline selector.""" + self.device = device + + WyomingSatelliteEntity.__init__(self, device) + AssistPipelineSelect.__init__(self, hass, device.satellite_id) + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + await super().async_select_option(option) + self.device.set_pipeline_name(option) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 20d73d8dc13..19b6a513d4b 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -9,6 +9,10 @@ }, "hassio_confirm": { "description": "Do you want to configure Home Assistant to connect to the Wyoming service provided by the add-on: {addon}?" + }, + "zeroconf_confirm": { + "description": "Do you want to configure Home Assistant to connect to the Wyoming service {name}?", + "title": "Discovered Wyoming service" } }, "error": { @@ -16,7 +20,31 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_services": "No services found at endpoint" + "no_services": "No services found at endpoint", + "no_port": "No port for endpoint" + } + }, + "entity": { + "binary_sensor": { + "assist_in_progress": { + "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + }, + "noise_suppression": { + "name": "Noise suppression" + } + }, + "switch": { + "satellite_enabled": { + "name": "Satellite enabled" + } } } } diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index e64a2f14667..8a21ef051fc 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -24,10 +25,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingSttProvider(config_entry, service), + WyomingSttProvider(config_entry, item.service), ] ) diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py new file mode 100644 index 00000000000..2bc43122588 --- /dev/null +++ b/homeassistant/components/wyoming/switch.py @@ -0,0 +1,65 @@ +"""Wyoming switch entities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteEnabledSwitch(item.satellite.device)]) + + +class WyomingSatelliteEnabledSwitch( + WyomingSatelliteEntity, restore_state.RestoreEntity, SwitchEntity +): + """Entity to represent if satellite is enabled.""" + + entity_description = SwitchEntityDescription( + key="satellite_enabled", + translation_key="satellite_enabled", + entity_category=EntityCategory.CONFIG, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + + # Default to on + self._attr_is_on = (state is None) or (state.state == STATE_ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + self._device.set_is_enabled(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + self._attr_is_on = False + self.async_write_ha_state() + self._device.set_is_enabled(False) diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index cde771cd330..f024f925514 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -16,6 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -26,10 +27,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingTtsProvider(config_entry, service), + WyomingTtsProvider(config_entry, item.service), ] ) diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index fce8bbf6327..da05e8c9fe1 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import WyomingService, load_wyoming_info from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -25,10 +26,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming wake-word-detection.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingWakeWordProvider(hass, config_entry, service), + WyomingWakeWordProvider(hass, config_entry, item.service), ] ) diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e8d117d1f33..55570078d80 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -715,6 +715,11 @@ ZEROCONF = { "domain": "wled", }, ], + "_wyoming._tcp.local.": [ + { + "domain": "wyoming", + }, + ], "_xbmc-jsonrpc-h._tcp.local.": [ { "domain": "kodi", diff --git a/requirements_all.txt b/requirements_all.txt index 0d62effff6c..76860344a5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2760,7 +2760,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.3.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0a05507e13..9d1cd7befce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2064,7 +2064,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.3.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index e04ff4eda03..899eda7ec1a 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,11 +1,13 @@ """Tests for the Wyoming integration.""" import asyncio +from wyoming.event import Event from wyoming.info import ( AsrModel, AsrProgram, Attribution, Info, + Satellite, TtsProgram, TtsVoice, TtsVoiceSpeaker, @@ -72,24 +74,36 @@ WAKE_WORD_INFO = Info( ) ] ) +SATELLITE_INFO = Info( + satellite=Satellite( + name="Test Satellite", + description="Test Satellite", + installed=True, + attribution=TEST_ATTR, + area="Office", + ) +) EMPTY_INFO = Info() class MockAsyncTcpClient: """Mock AsyncTcpClient.""" - def __init__(self, responses) -> None: + def __init__(self, responses: list[Event]) -> None: """Initialize.""" - self.host = None - self.port = None - self.written = [] + self.host: str | None = None + self.port: int | None = None + self.written: list[Event] = [] self.responses = responses - async def write_event(self, event): + async def connect(self) -> None: + """Connect.""" + + async def write_event(self, event: Event): """Send.""" self.written.append(event) - async def read_event(self): + async def read_event(self) -> Event | None: """Receive.""" await asyncio.sleep(0) # force context switch @@ -105,7 +119,7 @@ class MockAsyncTcpClient: async def __aexit__(self, exc_type, exc, tb): """Exit.""" - def __call__(self, host, port): + def __call__(self, host: str, port: int): """Call.""" self.host = host self.port = port diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 2c8081908f7..a30c1048eb6 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -5,14 +5,23 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components import stt +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component -from . import STT_INFO, TTS_INFO, WAKE_WORD_INFO +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def init_components(hass: HomeAssistant): + """Set up required components.""" + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" @@ -110,3 +119,39 @@ def metadata(hass: HomeAssistant) -> stt.SpeechMetadata: sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ) + + +@pytest.fixture +def satellite_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Create a config entry.""" + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_satellite(hass: HomeAssistant, satellite_config_entry: ConfigEntry): + """Initialize Wyoming satellite.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.run" + ) as _run_mock: + # _run_mock: satellite task does not actually run + await hass.config_entries.async_setup(satellite_config_entry.entry_id) + + +@pytest.fixture +async def satellite_device( + hass: HomeAssistant, init_satellite, satellite_config_entry: ConfigEntry +) -> SatelliteDevice: + """Get a satellite device fixture.""" + return hass.data[DOMAIN][satellite_config_entry.entry_id].satellite.device diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index d4220a39724..99f411027f5 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -121,3 +121,45 @@ 'version': 1, }) # --- +# name: test_zeroconf_discovery + FlowResultSnapshot({ + 'context': dict({ + 'name': 'Test Satellite', + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'Test Satellite', + }), + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'wyoming', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'disabled_by': None, + 'domain': 'wyoming', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'Test Satellite', + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + 'version': 1, + }), + 'title': 'Test Satellite', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py new file mode 100644 index 00000000000..27294186a90 --- /dev/null +++ b/tests/components/wyoming/test_binary_sensor.py @@ -0,0 +1,34 @@ +"""Test Wyoming binary sensor devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_assist_in_progress( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test assist in progress.""" + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active + + satellite_device.set_is_active(True) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_active + + satellite_device.set_is_active(False) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py index 896d3748ebd..f711b56b3bc 100644 --- a/tests/components/wyoming/test_config_flow.py +++ b/tests/components/wyoming/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Wyoming config flow.""" +from ipaddress import IPv4Address from unittest.mock import AsyncMock, patch import pytest @@ -8,10 +9,11 @@ from wyoming.info import Info from homeassistant import config_entries from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.wyoming.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import EMPTY_INFO, STT_INFO, TTS_INFO +from . import EMPTY_INFO, SATELLITE_INFO, STT_INFO, TTS_INFO from tests.common import MockConfigEntry @@ -25,6 +27,16 @@ ADDON_DISCOVERY = HassioServiceInfo( uuid="1234", ) +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=IPv4Address("127.0.0.1"), + ip_addresses=[IPv4Address("127.0.0.1")], + port=12345, + hostname="localhost", + type="_wyoming._tcp.local.", + name="test_zeroconf_name._wyoming._tcp.local.", + properties={}, +) + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -214,3 +226,70 @@ async def test_hassio_addon_no_supported_services(hass: HomeAssistant) -> None: assert result2.get("type") == FlowResultType.ABORT assert result2.get("reason") == "no_services" + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config flow initiated by Supervisor.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("description_placeholders") == { + "name": SATELLITE_INFO.satellite.name + } + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +async def test_zeroconf_discovery_no_port( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when the zeroconf service does not have a port.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch.object(ZEROCONF_DISCOVERY, "port", None): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_port" + + +async def test_zeroconf_discovery_no_services( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when there are no supported services on the client.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=Info(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_services" diff --git a/tests/components/wyoming/test_data.py b/tests/components/wyoming/test_data.py index 0cb878c39c1..b7de9dbfdc1 100644 --- a/tests/components/wyoming/test_data.py +++ b/tests/components/wyoming/test_data.py @@ -3,13 +3,15 @@ from __future__ import annotations from unittest.mock import patch -from homeassistant.components.wyoming.data import load_wyoming_info +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.wyoming.data import WyomingService, load_wyoming_info from homeassistant.core import HomeAssistant -from . import STT_INFO, MockAsyncTcpClient +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO, MockAsyncTcpClient -async def test_load_info(hass: HomeAssistant, snapshot) -> None: +async def test_load_info(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test loading info.""" with patch( "homeassistant.components.wyoming.data.AsyncTcpClient", @@ -38,3 +40,38 @@ async def test_load_info_oserror(hass: HomeAssistant) -> None: ) assert info is None + + +async def test_service_name(hass: HomeAssistant) -> None: + """Test loading service info.""" + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([STT_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == STT_INFO.asr[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([TTS_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == TTS_INFO.tts[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([WAKE_WORD_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == WAKE_WORD_INFO.wake[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([SATELLITE_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == SATELLITE_INFO.satellite.name diff --git a/tests/components/wyoming/test_devices.py b/tests/components/wyoming/test_devices.py new file mode 100644 index 00000000000..549f76f20f1 --- /dev/null +++ b/tests/components/wyoming/test_devices.py @@ -0,0 +1,78 @@ +"""Test Wyoming devices.""" +from __future__ import annotations + +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +async def test_device_registry_info( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + satellite_config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry.""" + + # Satellite uses config entry id since only one satellite per entry is + # supported. + device = device_registry.async_get_device( + identifiers={(DOMAIN, satellite_config_entry.entry_id)} + ) + assert device is not None + assert device.name == "Test Satellite" + assert device.suggested_area == "Office" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assist_in_progress_state = hass.states.get(assist_in_progress_id) + assert assist_in_progress_state is not None + assert assist_in_progress_state.state == STATE_OFF + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + satellite_enabled_state = hass.states.get(satellite_enabled_id) + assert satellite_enabled_state is not None + assert satellite_enabled_state.state == STATE_ON + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + pipeline_state = hass.states.get(pipeline_entity_id) + assert pipeline_state is not None + assert pipeline_state.state == OPTION_PREFERRED + + +async def test_remove_device_registry_entry( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing a device registry entry.""" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assert hass.states.get(assist_in_progress_id) is not None + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + assert hass.states.get(satellite_enabled_id) is not None + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + assert hass.states.get(pipeline_entity_id) is not None + + # Remove + device_registry.async_remove_device(satellite_device.device_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Everything should be gone + assert hass.states.get(assist_in_progress_id) is None + assert hass.states.get(satellite_enabled_id) is None + assert hass.states.get(pipeline_entity_id) is None diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py new file mode 100644 index 00000000000..06ae337a19c --- /dev/null +++ b/tests/components/wyoming/test_satellite.py @@ -0,0 +1,460 @@ +"""Test Wyoming satellite.""" +from __future__ import annotations + +import asyncio +import io +from unittest.mock import patch +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.event import Event +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, wyoming +from homeassistant.components.wyoming.data import WyomingService +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import SATELLITE_INFO, MockAsyncTcpClient + +from tests.common import MockConfigEntry + + +async def setup_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up config entry for Wyoming satellite. + + This is separated from the satellite_config_entry method in conftest.py so + we can patch functions before the satellite task is run during setup. + """ + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +def get_test_wav() -> bytes: + """Get bytes for test WAV file.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + + # Single frame + wav_file.writeframes(b"123") + + return wav_io.getvalue() + + +class SatelliteAsyncTcpClient(MockAsyncTcpClient): + """Satellite AsyncTcpClient.""" + + def __init__(self, responses: list[Event]) -> None: + """Initialize client.""" + super().__init__(responses) + + self.connect_event = asyncio.Event() + self.run_satellite_event = asyncio.Event() + self.detect_event = asyncio.Event() + + self.detection_event = asyncio.Event() + self.detection: Detection | None = None + + self.transcribe_event = asyncio.Event() + self.transcribe: Transcribe | None = None + + self.voice_started_event = asyncio.Event() + self.voice_started: VoiceStarted | None = None + + self.voice_stopped_event = asyncio.Event() + self.voice_stopped: VoiceStopped | None = None + + self.transcript_event = asyncio.Event() + self.transcript: Transcript | None = None + + self.synthesize_event = asyncio.Event() + self.synthesize: Synthesize | None = None + + self.tts_audio_start_event = asyncio.Event() + self.tts_audio_chunk_event = asyncio.Event() + self.tts_audio_stop_event = asyncio.Event() + self.tts_audio_chunk: AudioChunk | None = None + + self._mic_audio_chunk = AudioChunk( + rate=16000, width=2, channels=1, audio=b"chunk" + ).event() + + async def connect(self) -> None: + """Connect.""" + self.connect_event.set() + + async def write_event(self, event: Event): + """Send.""" + if RunSatellite.is_type(event.type): + self.run_satellite_event.set() + elif Detect.is_type(event.type): + self.detect_event.set() + elif Detection.is_type(event.type): + self.detection = Detection.from_event(event) + self.detection_event.set() + elif Transcribe.is_type(event.type): + self.transcribe = Transcribe.from_event(event) + self.transcribe_event.set() + elif VoiceStarted.is_type(event.type): + self.voice_started = VoiceStarted.from_event(event) + self.voice_started_event.set() + elif VoiceStopped.is_type(event.type): + self.voice_stopped = VoiceStopped.from_event(event) + self.voice_stopped_event.set() + elif Transcript.is_type(event.type): + self.transcript = Transcript.from_event(event) + self.transcript_event.set() + elif Synthesize.is_type(event.type): + self.synthesize = Synthesize.from_event(event) + self.synthesize_event.set() + elif AudioStart.is_type(event.type): + self.tts_audio_start_event.set() + elif AudioChunk.is_type(event.type): + self.tts_audio_chunk = AudioChunk.from_event(event) + self.tts_audio_chunk_event.set() + elif AudioStop.is_type(event.type): + self.tts_audio_stop_event.set() + + async def read_event(self) -> Event | None: + """Receive.""" + event = await super().read_event() + + # Keep sending audio chunks instead of None + return event or self._mic_audio_chunk + + +async def test_satellite_pipeline(hass: HomeAssistant) -> None: + """Test running a pipeline with a satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.tts.async_get_media_source_audio", + return_value=("wav", get_test_wav()), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + + # Start detecting wake word + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_START + ) + ) + async with asyncio.timeout(1): + await mock_client.detect_event.wait() + + assert not device.is_active + assert device.is_enabled + + # Wake word is detected + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_END, + {"wake_word_output": {"wake_word_id": "test_wake_word"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.detection_event.wait() + + assert mock_client.detection is not None + assert mock_client.detection.name == "test_wake_word" + + # "Assist in progress" sensor should be active now + assert device.is_active + + # Speech-to-text started + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_START, + {"metadata": {"language": "en"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcribe_event.wait() + + assert mock_client.transcribe is not None + assert mock_client.transcribe.language == "en" + + # User started speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_START, {"timestamp": 1234} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_started_event.wait() + + assert mock_client.voice_started is not None + assert mock_client.voice_started.timestamp == 1234 + + # User stopped speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_END, {"timestamp": 5678} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_stopped_event.wait() + + assert mock_client.voice_stopped is not None + assert mock_client.voice_stopped.timestamp == 5678 + + # Speech-to-text transcription + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_END, + {"stt_output": {"text": "test transcript"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcript_event.wait() + + assert mock_client.transcript is not None + assert mock_client.transcript.text == "test transcript" + + # Text-to-speech text + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + async with asyncio.timeout(1): + await mock_client.synthesize_event.wait() + + assert mock_client.synthesize is not None + assert mock_client.synthesize.text == "test text to speak" + assert mock_client.synthesize.voice is not None + assert mock_client.synthesize.voice.name == "test voice" + + # Text-to-speech media + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"media_id": "test media id"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.tts_audio_start_event.wait() + await mock_client.tts_audio_chunk_event.wait() + await mock_client.tts_audio_stop_event.wait() + + # Verify audio chunk from test WAV + assert mock_client.tts_audio_chunk is not None + assert mock_client.tts_audio_chunk.rate == 22050 + assert mock_client.tts_audio_chunk.width == 2 + assert mock_client.tts_audio_chunk.channels == 1 + assert mock_client.tts_audio_chunk.audio == b"123" + + # Pipeline finished + event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + assert not device.is_active + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_satellite_disabled(hass: HomeAssistant) -> None: + """Test callback for a satellite that has been disabled.""" + on_disabled_event = asyncio.Event() + + original_make_satellite = wyoming._make_satellite + + def make_disabled_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService + ): + satellite = original_make_satellite(hass, config_entry, service) + satellite.device.is_enabled = False + + return satellite + + async def on_disabled(self): + on_disabled_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming._make_satellite", make_disabled_satellite + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_disabled", + on_disabled, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_disabled_event.wait() + + +async def test_satellite_restart(hass: HomeAssistant) -> None: + """Test pipeline loop restart after unexpected error.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite._run_once", + side_effect=RuntimeError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + +async def test_satellite_reconnect(hass: HomeAssistant) -> None: + """Test satellite reconnect call after connection refused.""" + on_reconnect_event = asyncio.Event() + + async def on_reconnect(self): + self.stop() + on_reconnect_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient.connect", + side_effect=ConnectionRefusedError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_reconnect", + on_reconnect, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_reconnect_event.wait() + + +async def test_satellite_disconnect_before_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting before pipeline run.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient([]), # no RunPipeline event + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + # Pipeline should never have run + mock_run_pipeline.assert_not_called() + + +async def test_satellite_disconnect_during_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting during pipeline run.""" + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] # no audio chunks after RunPipeline + + on_restart_event = asyncio.Event() + on_stopped_event = asyncio.Event() + + async def on_restart(self): + # Pretend sensor got stuck on + self.device.is_active = True + self.stop() + on_restart_event.set() + + async def on_stopped(self): + on_stopped_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient(events), + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped", + on_stopped, + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await on_restart_event.wait() + await on_stopped_event.wait() + + # Pipeline should have run once + mock_run_pipeline.assert_called_once() + + # Sensor should have been turned off + assert not device.is_active diff --git a/tests/components/wyoming/test_select.py b/tests/components/wyoming/test_select.py new file mode 100644 index 00000000000..cab699336fb --- /dev/null +++ b/tests/components/wyoming/test_select.py @@ -0,0 +1,83 @@ +"""Test Wyoming select.""" +from unittest.mock import Mock, patch + +from homeassistant.components import assist_pipeline +from homeassistant.components.assist_pipeline.pipeline import PipelineData +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_pipeline_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test pipeline select. + + Functionality is tested in assist_pipeline/test_select.py. + This test is only to ensure it is set up. + """ + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + pipeline_data: PipelineData = hass.data[assist_pipeline.DOMAIN] + + # Create second pipeline + await pipeline_data.pipeline_store.async_create_item( + { + "name": "Test 1", + "language": "en-US", + "conversation_engine": None, + "conversation_language": "en-US", + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "stt_engine": None, + "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + + # Preferred pipeline is the default + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # Change to second pipeline + with patch.object(satellite_device, "set_pipeline_name") as mock_pipeline_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": "Test 1"}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "Test 1" + + # async_pipeline_changed should have been called + mock_pipeline_changed.assert_called_once_with("Test 1") + + # Change back and check update listener + pipeline_listener = Mock() + satellite_device.set_pipeline_listener(pipeline_listener) + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": OPTION_PREFERRED}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # listener should have been called + pipeline_listener.assert_called_once() diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py new file mode 100644 index 00000000000..0b05724d761 --- /dev/null +++ b/tests/components/wyoming/test_switch.py @@ -0,0 +1,32 @@ +"""Test Wyoming switch devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_satellite_enabled( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test satellite enabled.""" + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_enabled + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": satellite_enabled_id}, + blocking=True, + ) + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_enabled From 84e74e4c7409fc48b322db05b772732ddf4391e2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 08:43:58 +0100 Subject: [PATCH 466/643] Reverse component path (#104087) * Reverse component path * Update translations helper * Fix * Revert incorrect change of PLATFORM_FORMAT * Fix use of PLATFORM_FORMAT in tts * Fix ios --- .../components/device_tracker/legacy.py | 2 +- homeassistant/components/ios/notify.py | 4 ++-- homeassistant/components/notify/legacy.py | 2 +- homeassistant/components/tts/__init__.py | 2 +- homeassistant/helpers/entity_platform.py | 2 +- homeassistant/helpers/translation.py | 10 +++++----- homeassistant/setup.py | 4 ++-- .../device_tracker/test_config_entry.py | 2 +- tests/components/device_tracker/test_init.py | 4 ++-- tests/components/tts/test_init.py | 2 +- tests/components/weather/test_init.py | 2 +- tests/helpers/test_entity_component.py | 10 +++++----- tests/helpers/test_entity_platform.py | 12 ++++++------ tests/helpers/test_reload.py | 8 ++++---- tests/helpers/test_restore_state.py | 2 +- tests/helpers/test_translation.py | 16 ++++++++-------- tests/test_setup.py | 6 +++--- 17 files changed, 45 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 5f2a3c3ba52..264926a65bf 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -287,7 +287,7 @@ class DeviceTrackerPlatform: ) -> None: """Set up a legacy platform.""" assert self.type == PLATFORM_TYPE_LEGACY - full_name = f"{DOMAIN}.{self.name}" + full_name = f"{self.name}.{DOMAIN}" LOGGER.info("Setting up %s", full_name) with async_start_setup(hass, [full_name]): try: diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 2f42edb4bc1..de6091e3638 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -52,9 +52,9 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> iOSNotificationService | None: """Get the iOS notification service.""" - if "notify.ios" not in hass.config.components: + if "ios.notify" not in hass.config.components: # Need this to enable requirements checking in the app. - hass.config.components.add("notify.ios") + hass.config.components.add("ios.notify") if not ios.devices_with_push(hass): return None diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 30981cd3658..93b6833edc6 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -126,7 +126,7 @@ def async_setup_legacy( hass.data[NOTIFY_SERVICES].setdefault(integration_name, []).append( notify_service ) - hass.config.components.add(f"{DOMAIN}.{integration_name}") + hass.config.components.add(f"{integration_name}.{DOMAIN}") async def async_platform_discovered( platform: str, info: DiscoveryInfoType | None diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 38715825875..9a44382e851 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -545,7 +545,7 @@ class SpeechManager: self.providers[engine] = provider self.hass.config.components.add( - PLATFORM_FORMAT.format(domain=engine, platform=DOMAIN) + PLATFORM_FORMAT.format(domain=DOMAIN, platform=engine) ) @callback diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 2fc82567739..be087241287 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -304,7 +304,7 @@ class EntityPlatform: current_platform.set(self) logger = self.logger hass = self.hass - full_name = f"{self.domain}.{self.platform_name}" + full_name = f"{self.platform_name}.{self.domain}" object_id_language = ( hass.config.language if hass.config.language in languages.NATIVE_ENTITY_IDS diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 41ad591d878..d6a31085cfb 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -48,7 +48,7 @@ def component_translation_path( If component is just a single file, will return None. """ parts = component.split(".") - domain = parts[-1] + domain = parts[0] is_platform = len(parts) == 2 # If it's a component that is just one file, we don't support translations @@ -57,7 +57,7 @@ def component_translation_path( return None if is_platform: - filename = f"{parts[0]}.{language}.json" + filename = f"{parts[1]}.{language}.json" else: filename = f"{language}.json" @@ -96,7 +96,7 @@ def _merge_resources( # Build response resources: dict[str, dict[str, Any]] = {} for component in components: - domain = component.partition(".")[0] + domain = component.rpartition(".")[-1] domain_resources = resources.setdefault(domain, {}) @@ -154,7 +154,7 @@ async def _async_get_component_strings( # Determine paths of missing components/platforms files_to_load = {} for loaded in components: - domain = loaded.rpartition(".")[-1] + domain = loaded.partition(".")[0] integration = integrations[domain] path = component_translation_path(loaded, language, integration) @@ -225,7 +225,7 @@ class _TranslationCache: languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language] integrations: dict[str, Integration] = {} - domains = list({loaded.rpartition(".")[-1] for loaded in components}) + domains = list({loaded.partition(".")[0] for loaded in components}) ints_or_excs = await async_get_integrations(self.hass, domains) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 679042bc4e9..53e88f2aaa5 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -538,7 +538,7 @@ def async_get_loaded_integrations(hass: core.HomeAssistant) -> set[str]: if "." not in component: integrations.add(component) continue - domain, _, platform = component.partition(".") + platform, _, domain = component.partition(".") if domain in BASE_PLATFORMS: integrations.add(platform) return integrations @@ -563,7 +563,7 @@ def async_start_setup( time_taken = dt_util.utcnow() - started for unique, domain in unique_components.items(): del setup_started[unique] - integration = domain.rpartition(".")[-1] + integration = domain.partition(".")[0] if integration in setup_time: setup_time[integration] += time_taken else: diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index e55a9b5b6b2..49912fd282f 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -259,7 +259,7 @@ async def test_connected_device_registered( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 # should be disabled assert len(entity_registry.entities) == 3 diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 67bc24909c5..2960789c646 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -123,7 +123,7 @@ async def test_reading_yaml_config( assert device.config_picture == config.config_picture assert device.consider_home == config.consider_home assert device.icon == config.icon - assert f"{device_tracker.DOMAIN}.test" in hass.config.components + assert f"test.{device_tracker.DOMAIN}" in hass.config.components @patch("homeassistant.components.device_tracker.const.LOGGER.warning") @@ -603,7 +603,7 @@ async def test_bad_platform(hass: HomeAssistant) -> None: with assert_setup_component(0, device_tracker.DOMAIN): assert await async_setup_component(hass, device_tracker.DOMAIN, config) - assert f"{device_tracker.DOMAIN}.bad_platform" not in hass.config.components + assert f"bad_platform.{device_tracker.DOMAIN}" not in hass.config.components async def test_adding_unknown_device_to_config( diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 71be6b3bb11..5be56edbc32 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -150,7 +150,7 @@ async def test_restore_state( async def test_setup_component(hass: HomeAssistant, setup: str) -> None: """Set up a TTS platform with defaults.""" assert hass.services.has_service(tts.DOMAIN, "clear_cache") - assert f"{tts.DOMAIN}.test" in hass.config.components + assert f"test.{tts.DOMAIN}" in hass.config.components @pytest.mark.parametrize("init_tts_cache_dir_side_effect", [OSError(2, "No access")]) diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 3890d6a28d1..b982ab610ec 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1286,7 +1286,7 @@ async def test_issue_forecast_deprecated_no_logging( assert weather_entity.state == ATTR_CONDITION_SUNNY - assert "Setting up weather.test" in caplog.text + assert "Setting up test.weather" in caplog.text assert ( "custom_components.test_weather.weather::weather.test is using a forecast attribute on an instance of WeatherEntity" not in caplog.text diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 40e25633992..60d0774b549 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -215,7 +215,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # Should not trigger attempt 2 async_fire_time_changed(hass, utcnow + timedelta(seconds=29)) @@ -226,7 +226,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=30)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 2 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # This should not trigger attempt 3 async_fire_time_changed(hass, utcnow + timedelta(seconds=59)) @@ -237,7 +237,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 3 - assert "test_domain.mod1" in hass.config.components + assert "mod1.test_domain" in hass.config.components async def test_extract_from_service_fails_if_no_entity_id(hass: HomeAssistant) -> None: @@ -317,7 +317,7 @@ async def test_setup_dependencies_platform(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert "test_component" in hass.config.components assert "test_component2" in hass.config.components - assert "test_domain.test_component" in hass.config.components + assert "test_component.test_domain" in hass.config.components async def test_setup_entry(hass: HomeAssistant) -> None: @@ -680,7 +680,7 @@ async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None: await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components with patch.object( component._platforms[DOMAIN], "async_shutdown" diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 721114c1a7b..dfaec4577aa 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -268,7 +268,7 @@ async def test_platform_error_slow_setup( await component.async_setup({DOMAIN: {"platform": "test_platform"}}) await hass.async_block_till_done() assert len(called) == 1 - assert "test_domain.test_platform" not in hass.config.components + assert "test_platform.test_domain" not in hass.config.components assert "test_platform is taking longer than 0 seconds" in caplog.text # Cleanup lingering (setup_platform) task after test is done @@ -833,7 +833,7 @@ async def test_setup_entry( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 1 assert len(entity_registry.entities) == 1 @@ -856,7 +856,7 @@ async def test_setup_entry_platform_not_ready( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 assert "Platform test not ready yet" in caplog.text @@ -877,7 +877,7 @@ async def test_setup_entry_platform_not_ready_with_message( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -904,7 +904,7 @@ async def test_setup_entry_platform_not_ready_from_exception( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -1669,7 +1669,7 @@ async def test_setup_entry_with_entities_that_block_forever( ): assert await platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 assert len(entity_registry.entities) == 1 diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index 586dbc19eb8..4425ce00ce1 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -53,7 +53,7 @@ async def test_reload_platform(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) @@ -93,7 +93,7 @@ async def test_setup_reload_service(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -134,7 +134,7 @@ async def test_setup_reload_service_when_async_process_component_config_fails( await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -186,7 +186,7 @@ async def test_setup_reload_service_with_platform_that_provides_async_reset_plat await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index f01718d6af6..d69996e5d29 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -508,7 +508,7 @@ async def test_restore_entity_end_to_end( await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 6f5b4253218..62152299932 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -56,14 +56,14 @@ async def test_component_translation_path( ) assert path.normpath( - translation.component_translation_path("switch.test", "en", int_test) + translation.component_translation_path("test.switch", "en", int_test) ) == path.normpath( hass.config.path("custom_components", "test", "translations", "switch.en.json") ) assert path.normpath( translation.component_translation_path( - "switch.test_embedded", "en", int_test_embedded + "test_embedded.switch", "en", int_test_embedded ) ) == path.normpath( hass.config.path( @@ -255,7 +255,7 @@ async def test_translation_merging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we merge translations of two integrations.""" - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") hass.config.components.add("sensor") orig_load_translations = translation.load_translations_files @@ -263,7 +263,7 @@ async def test_translation_merging( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -276,13 +276,13 @@ async def test_translation_merging( assert "component.sensor.state.moon__phase.first_quarter" in translations - hass.config.components.add("sensor.season") + hass.config.components.add("season.sensor") # Patch in some bad translation data def mock_load_bad_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.season"] = {"state": "bad data"} + result["season.sensor"] = {"state": "bad data"} return result with patch( @@ -308,7 +308,7 @@ async def test_translation_merging_loaded_apart( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -323,7 +323,7 @@ async def test_translation_merging_loaded_apart( assert "component.sensor.state.moon__phase.first_quarter" not in translations - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") with patch( "homeassistant.helpers.translation.load_translations_files", diff --git a/tests/test_setup.py b/tests/test_setup.py index 00bb3fa2a2d..14c56d39a5a 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -672,7 +672,7 @@ async def test_async_get_loaded_integrations(hass: HomeAssistant) -> None: hass.config.components.add("notbase.switch") hass.config.components.add("myintegration") hass.config.components.add("device_tracker") - hass.config.components.add("device_tracker.other") + hass.config.components.add("other.device_tracker") hass.config.components.add("myintegration.light") assert setup.async_get_loaded_integrations(hass) == { "other", @@ -729,9 +729,9 @@ async def test_async_start_setup(hass: HomeAssistant) -> None: async def test_async_start_setup_platforms(hass: HomeAssistant) -> None: """Test setup started context manager keeps track of setup times for platforms.""" - with setup.async_start_setup(hass, ["sensor.august"]): + with setup.async_start_setup(hass, ["august.sensor"]): assert isinstance( - hass.data[setup.DATA_SETUP_STARTED]["sensor.august"], datetime.datetime + hass.data[setup.DATA_SETUP_STARTED]["august.sensor"], datetime.datetime ) assert "august" not in hass.data[setup.DATA_SETUP_STARTED] From e80ee09f5e6bab3beb9b1189d4bda5a3028bf746 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 08:50:32 +0100 Subject: [PATCH 467/643] Make UniFi WiFi clients numerical (#105032) --- homeassistant/components/unifi/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 3d0ffa1896e..eaa7cc6fe08 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -231,6 +231,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( key="WLAN clients", entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, allowed_fn=lambda controller, obj_id: True, api_handler_fn=lambda api: api.wlans, available_fn=async_wlan_available_fn, From 482e087a851da922a7c73d8bd1119569f107c796 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 08:55:15 +0100 Subject: [PATCH 468/643] Make unifi RX-/TX-sensors diagnostic entities (#105022) --- homeassistant/components/unifi/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index eaa7cc6fe08..4d5cf49b5c9 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -151,6 +151,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor RX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:upload", @@ -171,6 +172,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor TX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:download", From b7bc49b86366ee58971ba1756f354ebc37ee1e28 Mon Sep 17 00:00:00 2001 From: Marco <24938492+Marco98@users.noreply.github.com> Date: Tue, 5 Dec 2023 09:21:03 +0100 Subject: [PATCH 469/643] Fix Mikrotik rename from wifiwave2 to wifi for upcoming RouterOS 7.13 (#104966) Co-authored-by: Marco98 --- homeassistant/components/mikrotik/const.py | 4 ++++ homeassistant/components/mikrotik/hub.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 4354b9b06bd..8407dd14a6e 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -25,9 +25,11 @@ CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" WIRELESS: Final = "wireless" WIFIWAVE2: Final = "wifiwave2" +WIFI: Final = "wifi" IS_WIRELESS: Final = "is_wireless" IS_CAPSMAN: Final = "is_capsman" IS_WIFIWAVE2: Final = "is_wifiwave2" +IS_WIFI: Final = "is_wifi" MIKROTIK_SERVICES: Final = { @@ -38,9 +40,11 @@ MIKROTIK_SERVICES: Final = { INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", WIFIWAVE2: "/interface/wifiwave2/registration-table/print", + WIFI: "/interface/wifi/registration-table/print", IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", IS_WIFIWAVE2: "/interface/wifiwave2/print", + IS_WIFI: "/interface/wifi/print", } diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 9e0a610c770..af7dfb2ab2c 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -31,10 +31,12 @@ from .const import ( IDENTITY, INFO, IS_CAPSMAN, + IS_WIFI, IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + WIFI, WIFIWAVE2, WIRELESS, ) @@ -60,6 +62,7 @@ class MikrotikData: self.support_capsman: bool = False self.support_wireless: bool = False self.support_wifiwave2: bool = False + self.support_wifi: bool = False self.hostname: str = "" self.model: str = "" self.firmware: str = "" @@ -101,6 +104,7 @@ class MikrotikData: self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) + self.support_wifi = bool(self.command(MIKROTIK_SERVICES[IS_WIFI])) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -128,6 +132,9 @@ class MikrotikData: elif self.support_wifiwave2: _LOGGER.debug("Hub supports wifiwave2 Interface") device_list = wireless_devices = self.get_list_from_interface(WIFIWAVE2) + elif self.support_wifi: + _LOGGER.debug("Hub supports wifi Interface") + device_list = wireless_devices = self.get_list_from_interface(WIFI) if not device_list or self.force_dhcp: device_list = self.all_devices From c2cc8014dc5c8a117ed991c3bac85b4c0731dd0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Dec 2023 22:29:43 -1000 Subject: [PATCH 470/643] Avoid double URL creation for hassio ingress (#105052) --- homeassistant/components/hassio/ingress.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 751e9005809..0c0fe55b686 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -67,18 +67,20 @@ class HassIOIngress(HomeAssistantView): self._websession = websession @lru_cache - def _create_url(self, token: str, path: str) -> str: + def _create_url(self, token: str, path: str) -> URL: """Create URL to service.""" base_path = f"/ingress/{token}/" url = f"http://{self._host}{base_path}{quote(path)}" try: - if not URL(url).path.startswith(base_path): - raise HTTPBadRequest() + target_url = URL(url) except ValueError as err: raise HTTPBadRequest() from err - return url + if not target_url.path.startswith(base_path): + raise HTTPBadRequest() + + return target_url async def _handle( self, request: web.Request, token: str, path: str @@ -128,7 +130,7 @@ class HassIOIngress(HomeAssistantView): # Support GET query if request.query_string: - url = f"{url}?{request.query_string}" + url = url.with_query(request.query_string) # Start proxy async with self._websession.ws_connect( From 9b53fa6478481fb9014bdfcc6f29f193967fb5ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Dec 2023 22:30:21 -1000 Subject: [PATCH 471/643] Bump habluetooth to 0.6.1 (#105029) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 65d8b9cb892..24c1202a2fe 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.20.0", - "habluetooth==0.5.1" + "habluetooth==0.6.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3bfbdc9acd1..e9055eddebd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.20.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.5.1 +habluetooth==0.6.1 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index 76860344a5b..26b1f98f5a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.5.1 +habluetooth==0.6.1 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d1cd7befce..1b1e923b330 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.5.1 +habluetooth==0.6.1 # homeassistant.components.cloud hass-nabucasa==0.74.0 From 4b87936779b050df12da88dca175d439d8c91c37 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 09:42:43 +0100 Subject: [PATCH 472/643] Fix stuck clients in UniFi options (#105028) --- homeassistant/components/unifi/config_flow.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index a678517eca9..e1867b2df2e 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -8,6 +8,7 @@ Configuration of options through options flow. from __future__ import annotations from collections.abc import Mapping +import operator import socket from types import MappingProxyType from typing import Any @@ -309,6 +310,11 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): client.mac: f"{client.name or client.hostname} ({client.mac})" for client in self.controller.api.clients.values() } + clients |= { + mac: f"Unknown ({mac})" + for mac in self.options.get(CONF_CLIENT_SOURCE, []) + if mac not in clients + } return self.async_show_form( step_id="configure_entity_sources", @@ -317,7 +323,9 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_CLIENT_SOURCE, default=self.options.get(CONF_CLIENT_SOURCE, []), - ): cv.multi_select(clients), + ): cv.multi_select( + dict(sorted(clients.items(), key=operator.itemgetter(1))) + ), } ), last_step=False, From 5cab64bfcd7fc45396d7625f5c7d5cf187ac9c40 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 5 Dec 2023 09:48:46 +0100 Subject: [PATCH 473/643] Make season types translatable (#105027) --- .../components/season/config_flow.py | 19 ++++++++++++++----- homeassistant/components/season/strings.json | 8 ++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/season/config_flow.py b/homeassistant/components/season/config_flow.py index 39a52e57b10..069037e53a0 100644 --- a/homeassistant/components/season/config_flow.py +++ b/homeassistant/components/season/config_flow.py @@ -8,6 +8,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_TYPE from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL @@ -33,11 +38,15 @@ class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In( - { - TYPE_ASTRONOMICAL: "Astronomical", - TYPE_METEOROLOGICAL: "Meteorological", - } + vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): SelectSelector( + SelectSelectorConfig( + translation_key="season_type", + mode=SelectSelectorMode.LIST, + options=[ + TYPE_ASTRONOMICAL, + TYPE_METEOROLOGICAL, + ], + ) ) }, ), diff --git a/homeassistant/components/season/strings.json b/homeassistant/components/season/strings.json index 162daddd412..b0313d227a3 100644 --- a/homeassistant/components/season/strings.json +++ b/homeassistant/components/season/strings.json @@ -23,5 +23,13 @@ } } } + }, + "selector": { + "season_type": { + "options": { + "astronomical": "Astronomical", + "meteorological": "Meteorological" + } + } } } From ae002e2f3892dd2b6ed6c76b0721624a38285d2e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 10:48:31 +0100 Subject: [PATCH 474/643] Remove breaks_in_ha_version from deprecated YAML classes (#105062) --- homeassistant/util/yaml/loader.py | 4 ++-- tests/util/yaml/test_init.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 0d06ddfb757..275a51cd760 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -137,7 +137,7 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("FastSafeLoader", breaks_in_ha_version="2024.6") +@deprecated_class("FastSafeLoader") class SafeLoader(FastSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" @@ -151,7 +151,7 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("PythonSafeLoader", breaks_in_ha_version="2024.6") +@deprecated_class("PythonSafeLoader") class SafeLineLoader(PythonSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index a96d08933ee..6f6f48813ce 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -637,7 +637,7 @@ async def test_deprecated_loaders( loader_class() assert ( f"{loader_class.__name__} was instantiated from hue, this is a deprecated " - f"class which will be removed in HA Core 2024.6. Use {new_class} instead" + f"class. Use {new_class} instead" ) in caplog.text From 5b59e043fa0aea8819ccb91c2f41f460c7ca4185 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 11:36:26 +0100 Subject: [PATCH 475/643] Don't use deprecated_class decorator on deprecated YAML classes (#105063) --- homeassistant/util/yaml/loader.py | 60 ++++++++++++++++++++++++++++--- tests/util/yaml/test_init.py | 15 ++++---- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 275a51cd760..4a14afb53b2 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -1,7 +1,7 @@ """Custom loader.""" from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import suppress import fnmatch from io import StringIO, TextIOWrapper @@ -23,7 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.deprecation import deprecated_class +from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -137,10 +137,36 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("FastSafeLoader") class SafeLoader(FastSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.__init__(*args, **kwargs) + + @classmethod + def add_constructor(cls, tag: str, constructor: Callable) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.add_constructor(tag, constructor) + + @classmethod + def add_multi_constructor( + cls, tag_prefix: str, multi_constructor: Callable + ) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) + + @staticmethod + def __report_deprecated() -> None: + """Log deprecation warning.""" + report( + "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -151,10 +177,36 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("PythonSafeLoader") class SafeLineLoader(PythonSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.__init__(*args, **kwargs) + + @classmethod + def add_constructor(cls, tag: str, constructor: Callable) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.add_constructor(tag, constructor) + + @classmethod + def add_multi_constructor( + cls, tag_prefix: str, multi_constructor: Callable + ) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) + + @staticmethod + def __report_deprecated() -> None: + """Log deprecation warning.""" + report( + "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 6f6f48813ce..c4e5c58e235 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -590,7 +590,7 @@ async def test_loading_actual_file_with_syntax_error( def mock_integration_frame() -> Generator[Mock, None, None]: """Mock as if we're calling code from inside an integration.""" correct_frame = Mock( - filename="/home/paulus/.homeassistant/custom_components/hue/light.py", + filename="/home/paulus/homeassistant/components/hue/light.py", lineno="23", line="self.light.is_on", ) @@ -614,12 +614,12 @@ def mock_integration_frame() -> Generator[Mock, None, None]: @pytest.mark.parametrize( - ("loader_class", "new_class"), + ("loader_class", "message"), [ - (yaml.loader.SafeLoader, "FastSafeLoader"), + (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), ( yaml.loader.SafeLineLoader, - "PythonSafeLoader", + "'SafeLineLoader' instead of 'PythonSafeLoader'", ), ], ) @@ -628,17 +628,14 @@ async def test_deprecated_loaders( mock_integration_frame: Mock, caplog: pytest.LogCaptureFixture, loader_class, - new_class: str, + message: str, ) -> None: """Test instantiating the deprecated yaml loaders logs a warning.""" with pytest.raises(TypeError), patch( "homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set() ): loader_class() - assert ( - f"{loader_class.__name__} was instantiated from hue, this is a deprecated " - f"class. Use {new_class} instead" - ) in caplog.text + assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text def test_string_annotated(try_both_loaders) -> None: From 0638088aee66f0db31373d557c25b188997c4a50 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 5 Dec 2023 13:08:33 +0100 Subject: [PATCH 476/643] Deprecate and remove lazy_error from modbus (#105037) --- .../components/modbus/base_platform.py | 47 ++++++++++++++----- .../components/modbus/binary_sensor.py | 17 +++---- homeassistant/components/modbus/climate.py | 11 ++--- homeassistant/components/modbus/cover.py | 10 ++-- homeassistant/components/modbus/fan.py | 2 +- homeassistant/components/modbus/light.py | 2 +- homeassistant/components/modbus/sensor.py | 16 ++----- homeassistant/components/modbus/strings.json | 6 ++- homeassistant/components/modbus/switch.py | 2 +- tests/components/modbus/test_binary_sensor.py | 45 +----------------- tests/components/modbus/test_climate.py | 45 +----------------- tests/components/modbus/test_cover.py | 45 +----------------- tests/components/modbus/test_fan.py | 3 -- tests/components/modbus/test_light.py | 2 - tests/components/modbus/test_sensor.py | 41 +--------------- tests/components/modbus/test_switch.py | 45 +----------------- 16 files changed, 66 insertions(+), 273 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 1458abc0f25..1c7c8f65140 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -24,10 +24,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -61,6 +62,7 @@ from .const import ( CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, + MODBUS_DOMAIN, SIGNAL_START_ENTITY, SIGNAL_STOP_ENTITY, DataType, @@ -74,8 +76,34 @@ _LOGGER = logging.getLogger(__name__) class BasePlatform(Entity): """Base for readonly platforms.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: + def __init__( + self, hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any] + ) -> None: """Initialize the Modbus binary sensor.""" + + if CONF_LAZY_ERROR in entry: + async_create_issue( + hass, + MODBUS_DOMAIN, + "removed_lazy_error_count", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="removed_lazy_error_count", + translation_placeholders={ + "config_key": "lazy_error_count", + "integration": MODBUS_DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`close_comm_on_error`: is deprecated and will be removed in version 2024.4" + ) + + _LOGGER.warning( + "`lazy_error_count`: is deprecated and will be removed in version 2024.7" + ) + self._hub = hub self._slave = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) self._address = int(entry[CONF_ADDRESS]) @@ -92,8 +120,6 @@ class BasePlatform(Entity): self._attr_device_class = entry.get(CONF_DEVICE_CLASS) self._attr_available = True self._attr_unit_of_measurement = None - self._lazy_error_count = entry[CONF_LAZY_ERROR] - self._lazy_errors = self._lazy_error_count def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: @@ -153,9 +179,9 @@ class BasePlatform(Entity): class BaseStructPlatform(BasePlatform, RestoreEntity): """Base class representing a sensor/climate.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._swap = config[CONF_SWAP] self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] @@ -247,10 +273,10 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): """Base class representing a Modbus switch.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" config[CONF_INPUT_TYPE] = "" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._attr_is_on = False convert = { CALL_TYPE_REGISTER_HOLDING: ( @@ -343,15 +369,10 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._verify_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._attr_is_on = bool(result.bits[0] & 1) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 39174ae8931..6c0f6422df2 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -54,7 +54,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusBinarySensor(hub, entry, slave_count) + sensor = ModbusBinarySensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -64,12 +64,18 @@ async def async_setup_platform( class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): """Modbus binary sensor.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any], slave_count: int) -> None: + def __init__( + self, + hass: HomeAssistant, + hub: ModbusHub, + entry: dict[str, Any], + slave_count: int, + ) -> None: """Initialize the Modbus binary sensor.""" self._count = slave_count + 1 self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None self._result: list[int] = [] - super().__init__(hub, entry) + super().__init__(hass, hub, entry) async def async_setup_slaves( self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any] @@ -109,14 +115,9 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._result = [] else: - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._result = result.bits diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index df2983e9070..5de08803cd4 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -67,7 +67,7 @@ async def async_setup_platform( entities = [] for entity in discovery_info[CONF_CLIMATES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - entities.append(ModbusThermostat(hub, entity)) + entities.append(ModbusThermostat(hass, hub, entity)) async_add_entities(entities) @@ -79,11 +79,12 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus thermostat.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._target_temperature_register = config[CONF_TARGET_TEMP] self._target_temperature_write_registers = config[ CONF_TARGET_TEMP_WRITE_REGISTERS @@ -288,15 +289,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._slave, register, self._count, register_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return -1 - self._lazy_errors = self._lazy_error_count self._attr_available = False return -1 - self._lazy_errors = self._lazy_error_count - if raw: # Return the raw value read from the register, do not change # the object's state diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 27f9cb1fc18..072f1bb3d93 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -51,7 +51,7 @@ async def async_setup_platform( covers = [] for cover in discovery_info[CONF_COVERS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - covers.append(ModbusCover(hub, cover)) + covers.append(ModbusCover(hass, hub, cover)) async_add_entities(covers) @@ -63,11 +63,12 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus cover.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._state_closed = config[CONF_STATE_CLOSED] self._state_closing = config[CONF_STATE_CLOSING] self._state_open = config[CONF_STATE_OPEN] @@ -142,14 +143,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._slave, self._address, 1, self._input_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type == CALL_TYPE_COIL: self._set_attr_state(bool(result.bits[0] & 1)) diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index a986b243c1b..e5006b66f81 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_FANS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - fans.append(ModbusFan(hub, entry)) + fans.append(ModbusFan(hass, hub, entry)) async_add_entities(fans) diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 2e5ac62be21..acc01f39b46 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -29,7 +29,7 @@ async def async_setup_platform( lights = [] for entry in discovery_info[CONF_LIGHTS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - lights.append(ModbusLight(hub, entry)) + lights.append(ModbusLight(hass, hub, entry)) async_add_entities(lights) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 52aa37535d6..c015d117b13 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -1,7 +1,7 @@ """Support for Modbus Register sensors.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime import logging from typing import Any @@ -19,7 +19,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -53,7 +52,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusRegisterSensor(hub, entry, slave_count) + sensor = ModbusRegisterSensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -65,12 +64,13 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any], slave_count: int, ) -> None: """Initialize the modbus register sensor.""" - super().__init__(hub, entry) + super().__init__(hass, hub, entry) if slave_count: self._count = self._count * (slave_count + 1) self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None @@ -114,13 +114,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): self._slave, self._address, self._count, self._input_type ) if raw_result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - self._cancel_call = async_call_later( - self.hass, timedelta(seconds=1), self.async_update - ) - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._attr_native_value = None if self._coordinator: @@ -142,7 +135,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): else: self._attr_native_value = result self._attr_available = self._attr_native_value is not None - self._lazy_errors = self._lazy_error_count self.async_write_ha_state() diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 5f45d0df596..c549b59bf8f 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -70,9 +70,13 @@ } }, "issues": { + "removed_lazy_error_count": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue. All errors will be reported, as lazy_error_count is accepted but ignored" + }, "deprecated_close_comm_config": { "title": "`{config_key}` configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nCommunication is automatically closed on errors, see [the documentation]({url}) for other error handling parameters." + "description": "Please remove the `{config_key}` key from the {integration} entry in your `configuration.yaml` file and restart Home Assistant to fix this issue. All errors will be reported, as `lazy_error_count` is accepted but ignored." }, "deprecated_retry_on_empty": { "title": "`{config_key}` configuration key is being removed", diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index beb84096006..0c955ea409d 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_SWITCHES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - switches.append(ModbusSwitch(hub, entry)) + switches.append(ModbusSwitch(hass, hub, entry)) async_add_entities(switches) diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index a892dd205fb..e47a6165b30 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -1,5 +1,4 @@ """Thetests for the Modbus sensor component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN @@ -10,7 +9,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT, MODBUS_DOMAIN, @@ -26,13 +24,12 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") SLAVE_UNIQUE_ID = "ground_floor_sensor" @@ -57,7 +54,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SLAVE: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -69,7 +65,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_DEVICE_ADDRESS: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -196,44 +191,6 @@ async def test_all_binary_sensor(hass: HomeAssistant, expected, mock_do_cycle) - assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_BINARY_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [False * 16], - True, - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_binary_sensor( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index f2de0177c74..4b4ba00b4c6 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -1,5 +1,4 @@ """The tests for the Modbus climate component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN @@ -22,7 +21,6 @@ from homeassistant.components.modbus.const import ( CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_VALUES, CONF_HVAC_ONOFF_REGISTER, - CONF_LAZY_ERROR, CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_WRITE_REGISTERS, @@ -40,7 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") @@ -77,7 +75,6 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, CONF_DATA_TYPE: DataType.INT32, - CONF_LAZY_ERROR: 10, } ], }, @@ -581,46 +578,6 @@ async def test_restore_state_climate( assert state.attributes[ATTR_TEMPERATURE] == 37 -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_CLIMATES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_LAZY_ERROR: 1, - } - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x8000], - True, - "17", - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_climate( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory, start_expect, end_expect -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index b91b38b1f70..39897822bc8 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -1,5 +1,4 @@ """The tests for the Modbus cover component.""" -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -9,7 +8,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OPEN, @@ -33,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{COVER_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") ENTITY_ID2 = f"{ENTITY_ID}_2" @@ -59,7 +57,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -71,7 +68,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -127,45 +123,6 @@ async def test_coil_cover(hass: HomeAssistant, expected, mock_do_cycle) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_COVERS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OPEN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_cover( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 932e07b2d1a..0922329d4b7 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -11,7 +11,6 @@ from homeassistant.components.modbus.const import ( CONF_DEVICE_ADDRESS, CONF_FANS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -66,7 +65,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, @@ -84,7 +82,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_DEVICE_ADDRESS: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 1d6963aaa12..ecd9abd71b8 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -10,7 +10,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -55,7 +54,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index bb093c24af0..8fb7f9fd951 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,7 +1,6 @@ """The tests for the Modbus sensor component.""" import struct -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.modbus.const import ( @@ -10,7 +9,6 @@ from homeassistant.components.modbus.const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_NAN_VALUE, @@ -49,7 +47,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import mock_restore_cache_with_extra_data @@ -80,7 +78,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -97,7 +94,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -1142,41 +1138,6 @@ async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 1, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception"), - [ - ( - [0x8000], - True, - ), - ], -) -async def test_lazy_error_sensor( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == "17" - await do_next_cycle(hass, mock_do_cycle, 5) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 0eb40d2c082..28c44440581 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -2,7 +2,6 @@ from datetime import timedelta from unittest import mock -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -13,7 +12,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -39,7 +37,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import async_fire_time_changed @@ -64,7 +62,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, @@ -227,46 +224,6 @@ async def test_all_switch(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SWITCHES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - CONF_VERIFY: {}, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OFF, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_switch( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "mock_test_state", [(State(ENTITY_ID, STATE_ON),), (State(ENTITY_ID, STATE_OFF),)], From f460fdf63227631c7eada8c764eb73cf86895e7f Mon Sep 17 00:00:00 2001 From: Thomas Zahari Date: Tue, 5 Dec 2023 13:15:16 +0100 Subject: [PATCH 477/643] Add fields cancelled & extra to result of the departure HVV sensor (#105030) --- homeassistant/components/hvv_departures/sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index a8efb663c90..b30a9b375b0 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -30,6 +30,8 @@ ATTR_DIRECTION = "direction" ATTR_TYPE = "type" ATTR_DELAY = "delay" ATTR_NEXT = "next" +ATTR_CANCELLED = "cancelled" +ATTR_EXTRA = "extra" PARALLEL_UPDATES = 0 BERLIN_TIME_ZONE = get_time_zone("Europe/Berlin") @@ -142,6 +144,8 @@ class HVVDepartureSensor(SensorEntity): departure = data["departures"][0] line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) self._attr_available = True self._attr_native_value = ( departure_time @@ -157,6 +161,8 @@ class HVVDepartureSensor(SensorEntity): ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) @@ -164,6 +170,8 @@ class HVVDepartureSensor(SensorEntity): for departure in data["departures"]: line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) departures.append( { ATTR_DEPARTURE: departure_time @@ -175,6 +183,8 @@ class HVVDepartureSensor(SensorEntity): ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) self._attr_extra_state_attributes[ATTR_NEXT] = departures From 6e0ba8e726100e7c28890b854eb9c2fdbfc71af3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 5 Dec 2023 13:40:14 +0100 Subject: [PATCH 478/643] Improve matrix typing (#105067) --- homeassistant/components/matrix/__init__.py | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index ddda50aa8b2..44a65a2de59 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -7,7 +7,7 @@ import logging import mimetypes import os import re -from typing import NewType, TypedDict +from typing import Final, NewType, Required, TypedDict import aiofiles.os from nio import AsyncClient, Event, MatrixRoom @@ -49,11 +49,11 @@ _LOGGER = logging.getLogger(__name__) SESSION_FILE = ".matrix.conf" -CONF_HOMESERVER = "homeserver" -CONF_ROOMS = "rooms" -CONF_COMMANDS = "commands" -CONF_WORD = "word" -CONF_EXPRESSION = "expression" +CONF_HOMESERVER: Final = "homeserver" +CONF_ROOMS: Final = "rooms" +CONF_COMMANDS: Final = "commands" +CONF_WORD: Final = "word" +CONF_EXPRESSION: Final = "expression" CONF_USERNAME_REGEX = "^@[^:]*:.*" CONF_ROOMS_REGEX = "^[!|#][^:]*:.*" @@ -78,10 +78,10 @@ RoomAnyID = RoomID | RoomAlias class ConfigCommand(TypedDict, total=False): """Corresponds to a single COMMAND_SCHEMA.""" - name: str # CONF_NAME - rooms: list[RoomID] | None # CONF_ROOMS - word: WordCommand | None # CONF_WORD - expression: ExpressionCommand | None # CONF_EXPRESSION + name: Required[str] # CONF_NAME + rooms: list[RoomID] # CONF_ROOMS + word: WordCommand # CONF_WORD + expression: ExpressionCommand # CONF_EXPRESSION COMMAND_SCHEMA = vol.All( @@ -223,15 +223,15 @@ class MatrixBot: def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: # Set the command for all listening_rooms, unless otherwise specified. - command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # type: ignore[misc] + command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. if (word_command := command.get(CONF_WORD)) is not None: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._word_commands.setdefault(room_id, {}) - self._word_commands[room_id][word_command] = command # type: ignore[index] + self._word_commands[room_id][word_command] = command else: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._expression_commands.setdefault(room_id, []) self._expression_commands[room_id].append(command) @@ -263,7 +263,7 @@ class MatrixBot: # After single-word commands, check all regex commands in the room. for command in self._expression_commands.get(room_id, []): - match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] + match = command[CONF_EXPRESSION].match(message.body) if not match: continue message_data = { From c4fbc78c057bda72d922e6fd6425819b80294d46 Mon Sep 17 00:00:00 2001 From: GeoffAtHome Date: Tue, 5 Dec 2023 13:03:39 +0000 Subject: [PATCH 479/643] Fix geniushub smart plug state at start-up (#102110) * Smart plug did state wrong at start-up * Update docstring to reflect code --- homeassistant/components/geniushub/switch.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 79ba418d509..7b9bf8f6112 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -68,9 +68,12 @@ class GeniusSwitch(GeniusZone, SwitchEntity): def is_on(self) -> bool: """Return the current state of the on/off zone. - The zone is considered 'on' if & only if it is override/on (e.g. timer/on is 'off'). + The zone is considered 'on' if the mode is either 'override' or 'timer'. """ - return self._zone.data["mode"] == "override" and self._zone.data["setpoint"] + return ( + self._zone.data["mode"] in ["override", "timer"] + and self._zone.data["setpoint"] + ) async def async_turn_off(self, **kwargs: Any) -> None: """Send the zone to Timer mode. From db9d6b401a0898aabca9c5fd21ff381a6c3f41ef Mon Sep 17 00:00:00 2001 From: dupondje Date: Tue, 5 Dec 2023 14:28:57 +0100 Subject: [PATCH 480/643] Add optional dsmr timestamp sensor (#104979) * Add optional timestamp sensor * Apply suggestions from code review Remove "timestamp" translation Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/dsmr/sensor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 722b8eda326..6aadcd63d44 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -78,6 +78,13 @@ class DSMRSensorEntityDescription(SensorEntityDescription): SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( + DSMRSensorEntityDescription( + key="timestamp", + obis_reference=obis_references.P1_MESSAGE_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), DSMRSensorEntityDescription( key="current_electricity_usage", translation_key="current_electricity_usage", From 25bea91683f416c026f24deb48498890937e04de Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 15:06:13 +0100 Subject: [PATCH 481/643] Use modern platform path when reporting platform config errors (#104238) * Use modern platform path when reporting platform config errors * Update tests * Address review comment * Explicitly pass platform domain to log helpers * Revert overly complicated changes * Try a simpler solution --- homeassistant/config.py | 40 +++++++---- homeassistant/helpers/check_config.py | 8 ++- homeassistant/setup.py | 2 +- tests/components/rest/test_switch.py | 9 ++- tests/components/template/test_cover.py | 2 +- tests/components/trend/test_binary_sensor.py | 4 +- tests/helpers/test_check_config.py | 9 ++- tests/scripts/test_check_config.py | 5 +- tests/snapshots/test_config.ambr | 70 ++++++++++---------- 9 files changed, 89 insertions(+), 60 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 5d5d246884c..bbdd30c3683 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -141,7 +141,7 @@ class ConfigExceptionInfo: exception: Exception translation_key: ConfigErrorTranslationKey - platform_name: str + platform_path: str config: ConfigType integration_link: str | None @@ -659,7 +659,14 @@ def stringify_invalid( - Give a more user friendly output for unknown options - Give a more user friendly output for missing options """ - message_prefix = f"Invalid config for '{domain}'" + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + message_prefix = f"Invalid config for '{domain}'" if domain != CONF_CORE and link: message_suffix = f", please check the docs at {link}" else: @@ -730,7 +737,14 @@ def format_homeassistant_error( link: str | None = None, ) -> str: """Format HomeAssistantError thrown by a custom config validator.""" - message_prefix = f"Invalid config for '{domain}'" + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + message_prefix = f"Invalid config for '{domain}'" # HomeAssistantError raised by custom config validator has no path to the # offending configuration key, use the domain key as path instead. if annotation := find_annotation(config, [domain]): @@ -1064,7 +1078,7 @@ def _get_log_message_and_stack_print_pref( ) -> tuple[str | None, bool, dict[str, str]]: """Get message to log and print stack trace preference.""" exception = platform_exception.exception - platform_name = platform_exception.platform_name + platform_path = platform_exception.platform_path platform_config = platform_exception.config link = platform_exception.integration_link @@ -1088,7 +1102,7 @@ def _get_log_message_and_stack_print_pref( True, ), ConfigErrorTranslationKey.PLATFORM_VALIDATOR_UNKNOWN_ERR: ( - f"Unknown error validating {platform_name} platform config with {domain} " + f"Unknown error validating {platform_path} platform config with {domain} " "component platform schema", True, ), @@ -1101,7 +1115,7 @@ def _get_log_message_and_stack_print_pref( True, ), ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR: ( - f"Unknown error validating config for {platform_name} platform " + f"Unknown error validating config for {platform_path} platform " f"for {domain} component with PLATFORM_SCHEMA", True, ), @@ -1115,7 +1129,7 @@ def _get_log_message_and_stack_print_pref( show_stack_trace = False if isinstance(exception, vol.Invalid): log_message = format_schema_error( - hass, exception, platform_name, platform_config, link + hass, exception, platform_path, platform_config, link ) if annotation := find_annotation(platform_config, exception.path): placeholders["config_file"], line = annotation @@ -1124,9 +1138,9 @@ def _get_log_message_and_stack_print_pref( if TYPE_CHECKING: assert isinstance(exception, HomeAssistantError) log_message = format_homeassistant_error( - hass, exception, platform_name, platform_config, link + hass, exception, platform_path, platform_config, link ) - if annotation := find_annotation(platform_config, [platform_name]): + if annotation := find_annotation(platform_config, [platform_path]): placeholders["config_file"], line = annotation placeholders["line"] = str(line) show_stack_trace = True @@ -1363,7 +1377,7 @@ async def async_process_component_config( # noqa: C901 platforms: list[ConfigType] = [] for p_name, p_config in config_per_platform(config, domain): # Validate component specific platform schema - platform_name = f"{domain}.{p_name}" + platform_path = f"{p_name}.{domain}" try: p_validated = component_platform_schema(p_config) except vol.Invalid as exc: @@ -1400,7 +1414,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR, - platform_name, + platform_path, p_config, integration_docs, ) @@ -1413,7 +1427,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC, - platform_name, + platform_path, p_config, integration_docs, ) @@ -1428,7 +1442,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, - platform_name, + platform_path, p_config, p_integration.documentation, ) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 23707949dcd..59334c20b30 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -276,13 +276,17 @@ async def async_check_ha_config_file( # noqa: C901 # show errors for a missing integration in recovery mode or safe mode to # not confuse the user. if not hass.config.recovery_mode and not hass.config.safe_mode: - result.add_warning(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue except ( RequirementsNotFound, ImportError, ) as ex: - result.add_warning(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue # Validate platform specific schema diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 53e88f2aaa5..7a7f4323be6 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -263,7 +263,7 @@ async def _async_setup_component( if platform_exception.translation_key not in NOTIFY_FOR_TRANSLATION_KEYS: continue async_notify_setup_error( - hass, platform_exception.platform_name, platform_exception.integration_link + hass, platform_exception.platform_path, platform_exception.integration_link ) if processed_config is None: log_error("Invalid config.") diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index df90af44e73..7ded4fb0aed 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -62,8 +62,8 @@ async def test_setup_missing_config( await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) assert ( - "Invalid config for 'switch.rest': required key 'resource' not provided" - in caplog.text + "Invalid config for 'switch' from integration 'rest': required key 'resource' " + "not provided" in caplog.text ) @@ -75,7 +75,10 @@ async def test_setup_missing_schema( assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) - assert "Invalid config for 'switch.rest': invalid url" in caplog.text + assert ( + "Invalid config for 'switch' from integration 'rest': invalid url" + in caplog.text + ) @respx.mock diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 35f03ee9508..88f0fc366a3 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -424,7 +424,7 @@ async def test_template_open_or_position( ) -> None: """Test that at least one of open_cover or set_position is used.""" assert hass.states.async_all("cover") == [] - assert "Invalid config for 'cover.template'" in caplog_setup_text + assert "Invalid config for 'cover' from integration 'template'" in caplog_setup_text @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index ddd980ae970..1906c002101 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -513,6 +513,6 @@ async def test_invalid_min_sample( record = caplog.records[0] assert record.levelname == "ERROR" assert ( - "Invalid config for 'binary_sensor.trend': min_samples must be smaller than or equal to max_samples" - in record.message + "Invalid config for 'binary_sensor' from integration 'trend': min_samples must " + "be smaller than or equal to max_samples" in record.message ) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index b65f09aeaf9..de57fa0a8f3 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -227,7 +227,12 @@ async def test_platform_not_found(hass: HomeAssistant) -> None: assert res["light"] == [] warning = CheckConfigError( - "Platform error light.beer - Integration 'beer' not found.", None, None + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), + None, + None, ) _assert_warnings_errors(res, [warning], []) @@ -361,7 +366,7 @@ async def test_platform_import_error(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} warning = CheckConfigError( - "Platform error light.demo - blablabla", + "Platform error 'light' from integration 'demo' - blablabla", None, None, ) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 06dff1e0869..425ad561f50 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -78,7 +78,10 @@ def test_config_platform_valid( ( BASE_CONFIG + "light:\n platform: beer", {"homeassistant", "light"}, - "Platform error light.beer - Integration 'beer' not found.", + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), ), ], ) diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 7438bda5cde..26a44f60184 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -7,18 +7,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -63,18 +63,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -119,18 +119,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 ''', }), ]) @@ -143,18 +143,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 ''', }), ]) @@ -167,18 +167,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -255,18 +255,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 ''', }), ]) @@ -274,12 +274,12 @@ # name: test_component_config_validation_error_with_docs[basic] list([ "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided, please check the docs at https://www.home-assistant.io/integrations/iot_domain", - "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", - "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 ''', "Invalid config for 'adr_0007_2' at configuration.yaml, line 27: required key 'host' not provided, please check the docs at https://www.home-assistant.io/integrations/adr_0007_2", "Invalid config for 'adr_0007_3' at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo', please check the docs at https://www.home-assistant.io/integrations/adr_0007_3", From 3bcc6194efd5b10866506b37ead68f393b5a61a9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 15:07:32 +0100 Subject: [PATCH 482/643] Add domain key config validation (#104242) * Drop use of regex in helpers.extract_domain_configs * Update test * Revert test update * Add domain_from_config_key helper * Add validator * Address review comment * Update snapshots * Inline domain_from_config_key in validator --- homeassistant/bootstrap.py | 5 +- homeassistant/config.py | 32 ++++++++++-- homeassistant/helpers/check_config.py | 3 +- homeassistant/helpers/config_validation.py | 24 +++++++++ .../basic/configuration.yaml | 5 ++ .../basic_include/configuration.yaml | 3 ++ .../basic_include/integrations/.yaml | 0 .../basic_include/integrations/5.yaml | 0 .../integrations/iot_domain .yaml | 0 .../include_dir_list/invalid_domains/.yaml | 0 .../include_dir_list/invalid_domains/5.yaml | 0 .../invalid_domains/iot_domain .yaml | 0 .../packages/configuration.yaml | 7 +++ .../integrations/pack_5.yaml | 1 + .../integrations/pack_empty.yaml | 1 + .../integrations/pack_iot_domain_space.yaml | 1 + tests/helpers/test_config_validation.py | 16 ++++++ tests/snapshots/test_config.ambr | 51 +++++++++++++++++++ tests/test_config.py | 4 +- 19 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml create mode 100644 tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml create mode 100644 tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml create mode 100644 tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml create mode 100644 tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml create mode 100644 tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0998ac6274c..83b2f18719f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -27,6 +27,7 @@ from .const import ( from .exceptions import HomeAssistantError from .helpers import ( area_registry, + config_validation as cv, device_registry, entity, entity_registry, @@ -473,7 +474,9 @@ async def async_mount_local_lib_path(config_dir: str) -> str: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" # Filter out the repeating and common config section [homeassistant] - domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN} + domains = { + domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN + } # Add config entry domains if not hass.config.recovery_mode: diff --git a/homeassistant/config.py b/homeassistant/config.py index bbdd30c3683..6dd8bc21471 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -449,6 +449,19 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: base_exc.problem_mark.name = _relpath(hass, base_exc.problem_mark.name) raise + invalid_domains = [] + for key in config: + try: + cv.domain_key(key) + except vol.Invalid as exc: + suffix = "" + if annotation := find_annotation(config, exc.path): + suffix = f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" + _LOGGER.error("Invalid domain '%s'%s", key, suffix) + invalid_domains.append(key) + for invalid_domain in invalid_domains: + config.pop(invalid_domain) + core_config = config.get(CONF_CORE, {}) await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) return config @@ -982,9 +995,13 @@ async def merge_packages_config( for comp_name, comp_conf in pack_conf.items(): if comp_name == CONF_CORE: continue - # If component name is given with a trailing description, remove it - # when looking for component - domain = comp_name.partition(" ")[0] + try: + domain = cv.domain_key(comp_name) + except vol.Invalid: + _log_pkg_error( + hass, pack_name, comp_name, config, f"Invalid domain '{comp_name}'" + ) + continue try: integration = await async_get_integration_with_requirements( @@ -1263,8 +1280,13 @@ def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: Async friendly. """ - pattern = re.compile(rf"^{domain}(| .+)$") - return [key for key in config if pattern.match(key)] + domain_configs = [] + for key in config: + with suppress(vol.Invalid): + if cv.domain_key(key) != domain: + continue + domain_configs.append(key) + return domain_configs async def async_process_component_config( # noqa: C901 diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 59334c20b30..1c8efadfdc5 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -31,6 +31,7 @@ from homeassistant.requirements import ( ) import homeassistant.util.yaml.loader as yaml_loader +from . import config_validation as cv from .typing import ConfigType @@ -175,7 +176,7 @@ async def async_check_ha_config_file( # noqa: C901 core_config.pop(CONF_PACKAGES, None) # Filter out repeating config sections - components = {key.partition(" ")[0] for key in config} + components = {cv.domain_key(key) for key in config} frontend_dependencies: set[str] = set() if "frontend" in components or "default_config" in components: diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e07596ad450..e4b62dd679d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -351,6 +351,30 @@ comp_entity_ids_or_uuids = vol.Any( ) +def domain_key(config_key: Any) -> str: + """Validate a top level config key with an optional label and return the domain. + + A domain is separated from a label by one or more spaces, empty labels are not + allowed. + + Examples: + 'hue' returns 'hue' + 'hue 1' returns 'hue' + 'hue 1' returns 'hue' + 'hue ' raises + 'hue ' raises + """ + if not isinstance(config_key, str): + raise vol.Invalid("invalid domain", path=[config_key]) + + parts = config_key.partition(" ") + _domain = parts[0] if parts[2].strip(" ") else config_key + if not _domain or _domain.strip(" ") != _domain: + raise vol.Invalid("invalid domain", path=[config_key]) + + return _domain + + def entity_domain(domain: str | list[str]) -> Callable[[Any], str]: """Validate that entity belong to domain.""" ent_domain = entities_domain(domain) diff --git a/tests/fixtures/core/config/component_validation/basic/configuration.yaml b/tests/fixtures/core/config/component_validation/basic/configuration.yaml index 9c3d1eb190b..49db89f45ba 100644 --- a/tests/fixtures/core/config/component_validation/basic/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic/configuration.yaml @@ -56,3 +56,8 @@ custom_validator_bad_1: # This always raises ValueError custom_validator_bad_2: + +# Invalid domains +"iot_domain ": +"": +5: diff --git a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml index 5744e3005fa..8e1c75c3511 100644 --- a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml @@ -8,3 +8,6 @@ custom_validator_ok_1: !include integrations/custom_validator_ok_1.yaml custom_validator_ok_2: !include integrations/custom_validator_ok_2.yaml custom_validator_bad_1: !include integrations/custom_validator_bad_1.yaml custom_validator_bad_2: !include integrations/custom_validator_bad_2.yaml +"iot_domain ": !include integrations/iot_domain .yaml +"": !include integrations/.yaml +5: !include integrations/5.yaml diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/packages/configuration.yaml b/tests/fixtures/core/config/component_validation/packages/configuration.yaml index b8116b5988e..25d734b126a 100644 --- a/tests/fixtures/core/config/component_validation/packages/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/packages/configuration.yaml @@ -68,3 +68,10 @@ homeassistant: pack_custom_validator_bad_2: # This always raises ValueError custom_validator_bad_2: + # Invalid domains + pack_iot_domain_space: + "iot_domain ": + pack_empty: + "": + pack_5: + 5: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml new file mode 100644 index 00000000000..70bf80a6b64 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml @@ -0,0 +1 @@ +5: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml new file mode 100644 index 00000000000..510d4682445 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml @@ -0,0 +1 @@ +"": diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml new file mode 100644 index 00000000000..49b5720a536 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml @@ -0,0 +1 @@ +"iot_domain ": diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index b44137e4f5c..f997e3a6c10 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1631,3 +1631,19 @@ def test_platform_only_schema( cv.platform_only_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) assert expected_message in caplog.text assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) + + +def test_domain() -> None: + """Test domain.""" + with pytest.raises(vol.Invalid): + cv.domain_key(5) + with pytest.raises(vol.Invalid): + cv.domain_key("") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + assert cv.domain_key("hue") == "hue" + assert cv.domain_key("hue1") == "hue1" + assert cv.domain_key("hue 1") == "hue" + assert cv.domain_key("hue 1") == "hue" diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 26a44f60184..76d3f0c4666 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,6 +1,18 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 61", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 62", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided", @@ -57,6 +69,18 @@ # --- # name: test_component_config_validation_error[basic_include] list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 11", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 12", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 5: required key 'platform' not provided", @@ -161,6 +185,18 @@ # --- # name: test_component_config_validation_error[packages] list([ + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_iot_domain_space' at configuration.yaml, line 72 failed: Invalid domain 'iot_domain '", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at configuration.yaml, line 74 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_5' at configuration.yaml, line 76 failed: Invalid domain '5'", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 11: required key 'platform' not provided", @@ -217,6 +253,18 @@ # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_5' at integrations/pack_5.yaml, line 1 failed: Invalid domain '5'", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at integrations/pack_empty.yaml, line 1 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_iot_domain_space' at integrations/pack_iot_domain_space.yaml, line 1 failed: Invalid domain 'iot_domain '", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'adr_0007_2' at integrations/adr_0007_2.yaml, line 2: required key 'host' not provided", @@ -273,6 +321,9 @@ # --- # name: test_component_config_validation_error_with_docs[basic] list([ + "Invalid domain 'iot_domain ' at configuration.yaml, line 61", + "Invalid domain '' at configuration.yaml, line 62", + "Invalid domain '5' at configuration.yaml, line 1", "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided, please check the docs at https://www.home-assistant.io/integrations/iot_domain", "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", diff --git a/tests/test_config.py b/tests/test_config.py index 1e309e2908f..8ec509cd895 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2043,7 +2043,7 @@ async def test_component_config_validation_error( for domain_with_label in config: integration = await async_get_integration( - hass, domain_with_label.partition(" ")[0] + hass, cv.domain_key(domain_with_label) ) await config_util.async_process_component_and_handle_errors( hass, @@ -2088,7 +2088,7 @@ async def test_component_config_validation_error_with_docs( for domain_with_label in config: integration = await async_get_integration( - hass, domain_with_label.partition(" ")[0] + hass, cv.domain_key(domain_with_label) ) await config_util.async_process_component_and_handle_errors( hass, From 55bafc260d807cc0172c47a1133ea27af6c39bfd Mon Sep 17 00:00:00 2001 From: GeoffAtHome Date: Tue, 5 Dec 2023 13:03:39 +0000 Subject: [PATCH 483/643] Fix geniushub smart plug state at start-up (#102110) * Smart plug did state wrong at start-up * Update docstring to reflect code --- homeassistant/components/geniushub/switch.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 79ba418d509..7b9bf8f6112 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -68,9 +68,12 @@ class GeniusSwitch(GeniusZone, SwitchEntity): def is_on(self) -> bool: """Return the current state of the on/off zone. - The zone is considered 'on' if & only if it is override/on (e.g. timer/on is 'off'). + The zone is considered 'on' if the mode is either 'override' or 'timer'. """ - return self._zone.data["mode"] == "override" and self._zone.data["setpoint"] + return ( + self._zone.data["mode"] in ["override", "timer"] + and self._zone.data["setpoint"] + ) async def async_turn_off(self, **kwargs: Any) -> None: """Send the zone to Timer mode. From 655b067277018fa1f869c5ff39ea0699041f690f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 13:37:43 -0800 Subject: [PATCH 484/643] Add due date and description to Google Tasks (#104654) * Add tests for config validation function * Add Google Tasks due date and description * Revert test timezone * Update changes after upstream * Update homeassistant/components/google_tasks/todo.py Co-authored-by: Martin Hjelmare * Add google tasks tests for creating --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/google_tasks/todo.py | 39 ++++++--- .../google_tasks/snapshots/test_todo.ambr | 58 ++++++++++--- tests/components/google_tasks/test_todo.py | 85 +++++++++---------- 3 files changed, 114 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index d3c4dfa6936..130c0d2cc01 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -1,7 +1,7 @@ """Google Tasks todo platform.""" from __future__ import annotations -from datetime import timedelta +from datetime import date, datetime, timedelta from typing import Any, cast from homeassistant.components.todo import ( @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .api import AsyncConfigEntryAuth from .const import DOMAIN @@ -35,9 +36,31 @@ def _convert_todo_item(item: TodoItem) -> dict[str, str]: result["title"] = item.summary if item.status is not None: result["status"] = TODO_STATUS_MAP_INV[item.status] + if (due := item.due) is not None: + # due API field is a timestamp string, but with only date resolution + result["due"] = dt_util.start_of_local_day(due).isoformat() + if (description := item.description) is not None: + result["notes"] = description return result +def _convert_api_item(item: dict[str, str]) -> TodoItem: + """Convert tasks API items into a TodoItem.""" + due: date | None = None + if (due_str := item.get("due")) is not None: + due = datetime.fromisoformat(due_str).date() + return TodoItem( + summary=item["title"], + uid=item["id"], + status=TODO_STATUS_MAP.get( + item.get("status", ""), + TodoItemStatus.NEEDS_ACTION, + ), + due=due, + description=item.get("notes"), + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -68,6 +91,8 @@ class GoogleTaskTodoListEntity( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -88,17 +113,7 @@ class GoogleTaskTodoListEntity( """Get the current set of To-do items.""" if self.coordinator.data is None: return None - return [ - TodoItem( - summary=item["title"], - uid=item["id"], - status=TODO_STATUS_MAP.get( - item.get("status"), # type: ignore[arg-type] - TodoItemStatus.NEEDS_ACTION, - ), - ) - for item in _order_tasks(self.coordinator.data) - ] + return [_convert_api_item(item) for item in _order_tasks(self.coordinator.data)] async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index 73289b313d9..7d6eb920593 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -1,11 +1,29 @@ # serializer version: 1 -# name: test_create_todo_list_item[api_responses0] +# name: test_create_todo_list_item[description] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', 'POST', ) # --- -# name: test_create_todo_list_item[api_responses0].1 +# name: test_create_todo_list_item[description].1 + '{"title": "Soda", "status": "needsAction", "notes": "6-pack"}' +# --- +# name: test_create_todo_list_item[due] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[due].1 + '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_create_todo_list_item[summary] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[summary].1 '{"title": "Soda", "status": "needsAction"}' # --- # name: test_delete_todo_list_item[_handler] @@ -38,6 +56,33 @@ }), ]) # --- +# name: test_partial_update[description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[description].1 + '{"notes": "6-pack"}' +# --- +# name: test_partial_update[due_date] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[due_date].1 + '{"due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_partial_update[rename] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[rename].1 + '{"title": "Soda"}' +# --- # name: test_partial_update_status[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', @@ -47,15 +92,6 @@ # name: test_partial_update_status[api_responses0].1 '{"status": "needsAction"}' # --- -# name: test_partial_update_title[api_responses0] - tuple( - 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', - 'PATCH', - ) -# --- -# name: test_partial_update_title[api_responses0].1 - '{"title": "Soda"}' -# --- # name: test_update_todo_list_item[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 0b82815b33a..3329f89c1ca 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -19,13 +19,12 @@ from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator ENTITY_ID = "todo.my_tasks" +ITEM = { + "id": "task-list-id-1", + "title": "My tasks", +} LIST_TASK_LIST_RESPONSE = { - "items": [ - { - "id": "task-list-id-1", - "title": "My tasks", - }, - ] + "items": [ITEM], } EMPTY_RESPONSE = {} LIST_TASKS_RESPONSE = { @@ -76,6 +75,20 @@ LIST_TASKS_RESPONSE_MULTIPLE = { ], } +# API responses when testing update methods +UPDATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update +] +CREATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # create + LIST_TASKS_RESPONSE, # refresh +] + @pytest.fixture def platforms() -> list[str]: @@ -207,12 +220,14 @@ def mock_http_response(response_handler: list | Callable) -> Mock: "title": "Task 1", "status": "needsAction", "position": "0000000000000001", + "due": "2023-11-18T00:00:00+00:00", }, { "id": "task-2", "title": "Task 2", "status": "completed", "position": "0000000000000002", + "notes": "long description", }, ], }, @@ -238,11 +253,13 @@ async def test_get_items( "uid": "task-1", "summary": "Task 1", "status": "needs_action", + "due": "2023-11-18", }, { "uid": "task-2", "summary": "Task 2", "status": "completed", + "description": "long description", }, ] @@ -333,21 +350,20 @@ async def test_task_items_error_response( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, - EMPTY_RESPONSE, # create - LIST_TASKS_RESPONSE, # refresh after delete - ] + (CREATE_API_RESPONSES, {}), + (CREATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (CREATE_API_RESPONSES, {"description": "6-pack"}), ], + ids=["summary", "due", "description"], ) async def test_create_todo_list_item( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Mock, + item_data: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for creating a To-do Item.""" @@ -361,7 +377,7 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -407,17 +423,7 @@ async def test_create_todo_list_item_error( ) -@pytest.mark.parametrize( - "api_responses", - [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] - ], -) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_update_todo_list_item( hass: HomeAssistant, setup_credentials: None, @@ -483,21 +489,20 @@ async def test_update_todo_list_item_error( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] + (UPDATE_API_RESPONSES, {"rename": "Soda"}), + (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (UPDATE_API_RESPONSES, {"description": "6-pack"}), ], + ids=("rename", "due_date", "description"), ) -async def test_partial_update_title( +async def test_partial_update( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Any, + item_data: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for partial update with title only.""" @@ -511,7 +516,7 @@ async def test_partial_update_title( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "some-task-id", "rename": "Soda"}, + {"item": "some-task-id", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -522,17 +527,7 @@ async def test_partial_update_title( assert call.kwargs.get("body") == snapshot -@pytest.mark.parametrize( - "api_responses", - [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] - ], -) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_partial_update_status( hass: HomeAssistant, setup_credentials: None, From db6b80429856bb066193a808457852380c49df18 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Nov 2023 22:01:57 -0800 Subject: [PATCH 485/643] Add due date and description fields to Todoist To-do entity (#104655) * Add Todoist Due date and description fields * Update entity features with new names * Make items into walrus * Update due_datetime field * Add additional tests for adding new fields to items * Fix call args in todoist test --- homeassistant/components/todoist/todo.py | 41 ++++- tests/components/todoist/conftest.py | 5 +- tests/components/todoist/test_todo.py | 211 +++++++++++++++++++++-- 3 files changed, 236 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index c0d3ec6e2ce..64e83b8cc6e 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -1,7 +1,8 @@ """A todo platform for Todoist.""" import asyncio -from typing import cast +import datetime +from typing import Any, cast from homeassistant.components.todo import ( TodoItem, @@ -13,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import TodoistCoordinator @@ -30,6 +32,24 @@ async def async_setup_entry( ) +def _task_api_data(item: TodoItem) -> dict[str, Any]: + """Convert a TodoItem to the set of add or update arguments.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["content"] = summary + if due := item.due: + if isinstance(due, datetime.datetime): + item_data["due"] = { + "date": due.date().isoformat(), + "datetime": due.isoformat(), + } + else: + item_data["due"] = {"date": due.isoformat()} + if description := item.description: + item_data["description"] = description + return item_data + + class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity): """A Todoist TodoListEntity.""" @@ -37,6 +57,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -66,11 +89,21 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit status = TodoItemStatus.COMPLETED else: status = TodoItemStatus.NEEDS_ACTION + due: datetime.date | datetime.datetime | None = None + if task_due := task.due: + if task_due.datetime: + due = dt_util.as_local( + datetime.datetime.fromisoformat(task_due.datetime) + ) + elif task_due.date: + due = datetime.date.fromisoformat(task_due.date) items.append( TodoItem( summary=task.content, uid=task.id, status=status, + due=due, + description=task.description or None, # Don't use empty string ) ) self._attr_todo_items = items @@ -81,7 +114,7 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit if item.status != TodoItemStatus.NEEDS_ACTION: raise ValueError("Only active tasks may be created.") await self.coordinator.api.add_task( - content=item.summary or "", + **_task_api_data(item), project_id=self._project_id, ) await self.coordinator.async_refresh() @@ -89,8 +122,8 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit async def async_update_todo_item(self, item: TodoItem) -> None: """Update a To-do item.""" uid: str = cast(str, item.uid) - if item.summary: - await self.coordinator.api.update_task(task_id=uid, content=item.summary) + if update_data := _task_api_data(item): + await self.coordinator.api.update_task(task_id=uid, **update_data) if item.status is not None: if item.status == TodoItemStatus.COMPLETED: await self.coordinator.api.close_task(task_id=uid) diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 28f22e1061a..4e4d41b6914 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -45,6 +45,7 @@ def make_api_task( is_completed: bool = False, due: Due | None = None, project_id: str | None = None, + description: str | None = None, ) -> Task: """Mock a todoist Task instance.""" return Task( @@ -55,8 +56,8 @@ def make_api_task( content=content or SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", - description="A task", - due=due or Due(is_recurring=False, date=TODAY, string="today"), + description=description, + due=due, id=id or "1", labels=["Label1"], order=1, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index fb6f707be47..aa00e2c2ff4 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -1,7 +1,9 @@ """Unit tests for the Todoist todo platform.""" +from typing import Any from unittest.mock import AsyncMock import pytest +from todoist_api_python.models import Due, Task from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import Platform @@ -19,6 +21,12 @@ def platforms() -> list[Platform]: return [Platform.TODO] +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + @pytest.mark.parametrize( ("tasks", "expected_state"), [ @@ -57,11 +65,91 @@ async def test_todo_item_state( assert state.state == expected_state -@pytest.mark.parametrize(("tasks"), [[]]) +@pytest.mark.parametrize( + ("tasks", "item_data", "tasks_after_update", "add_kwargs", "expected_item"), + [ + ( + [], + {}, + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"content": "Soda"}, + {"uid": "task-id-1", "summary": "Soda", "status": "needs_action"}, + ), + ( + [], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["summary", "due_date", "due_datetime", "description"], +) async def test_add_todo_list_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + item_data: dict[str, Any], + tasks_after_update: list[Task], + add_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for adding a To-do Item.""" @@ -71,28 +159,35 @@ async def test_add_todo_list_item( api.add_task = AsyncMock() # Fake API response when state is refreshed after create - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=False) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.name"}, blocking=True, ) args = api.add_task.call_args assert args - assert args.kwargs.get("content") == "Soda" - assert args.kwargs.get("project_id") == PROJECT_ID + assert args.kwargs == {"project_id": PROJECT_ID, "content": "Soda", **add_kwargs} # Verify state is refreshed state = hass.states.get("todo.name") assert state assert state.state == "1" + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} + @pytest.mark.parametrize( ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] @@ -158,12 +253,91 @@ async def test_update_todo_item_status( @pytest.mark.parametrize( - ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] + ("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"), + [ + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"rename": "Milk"}, + [make_api_task(id="task-id-1", content="Milk", is_completed=False)], + {"task_id": "task-id-1", "content": "Milk"}, + {"uid": "task-id-1", "summary": "Milk", "status": "needs_action"}, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"task_id": "task-id-1", "due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "task_id": "task-id-1", + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"task_id": "task-id-1", "description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["rename", "due_date", "due_datetime", "description"], ) -async def test_update_todo_item_summary( +async def test_update_todo_items( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + update_data: dict[str, Any], + tasks_after_update: list[Task], + update_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for updating a To-do Item that changes the summary.""" @@ -174,22 +348,29 @@ async def test_update_todo_item_summary( api.update_task = AsyncMock() # Fake API response when state is refreshed after close - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=True) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "task-id-1", "rename": "Milk"}, + {"item": "task-id-1", **update_data}, target={"entity_id": "todo.name"}, blocking=True, ) assert api.update_task.called args = api.update_task.call_args assert args - assert args.kwargs.get("task_id") == "task-id-1" - assert args.kwargs.get("content") == "Milk" + assert args.kwargs == update_kwargs + + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} @pytest.mark.parametrize( From 5a49e1dd5cbf1ab20eb9c2b30f5ca3eb13166662 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 4 Dec 2023 14:13:15 -0600 Subject: [PATCH 486/643] Add Wyoming satellite (#104759) * First draft of Wyoming satellite * Set up homeassistant in tests * Move satellite * Add devices with binary sensor and select * Add more events * Add satellite enabled switch * Fix mistake * Only set up necessary platforms for satellites * Lots of fixes * Add tests * Use config entry id as satellite id * Initial satellite test * Add satellite pipeline test * More tests * More satellite tests * Only support single device per config entry * Address comments * Make a copy of platforms --- homeassistant/components/wyoming/__init__.py | 77 ++- .../components/wyoming/binary_sensor.py | 55 +++ .../components/wyoming/config_flow.py | 91 +++- homeassistant/components/wyoming/data.py | 39 +- homeassistant/components/wyoming/devices.py | 85 ++++ homeassistant/components/wyoming/entity.py | 24 + .../components/wyoming/manifest.json | 4 +- homeassistant/components/wyoming/models.py | 13 + homeassistant/components/wyoming/satellite.py | 380 +++++++++++++++ homeassistant/components/wyoming/select.py | 47 ++ homeassistant/components/wyoming/strings.json | 30 +- homeassistant/components/wyoming/stt.py | 5 +- homeassistant/components/wyoming/switch.py | 65 +++ homeassistant/components/wyoming/tts.py | 5 +- homeassistant/components/wyoming/wake_word.py | 5 +- homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wyoming/__init__.py | 28 +- tests/components/wyoming/conftest.py | 47 +- .../wyoming/snapshots/test_config_flow.ambr | 42 ++ .../components/wyoming/test_binary_sensor.py | 34 ++ tests/components/wyoming/test_config_flow.py | 81 ++- tests/components/wyoming/test_data.py | 43 +- tests/components/wyoming/test_devices.py | 78 +++ tests/components/wyoming/test_satellite.py | 460 ++++++++++++++++++ tests/components/wyoming/test_select.py | 83 ++++ tests/components/wyoming/test_switch.py | 32 ++ 28 files changed, 1802 insertions(+), 60 deletions(-) create mode 100644 homeassistant/components/wyoming/binary_sensor.py create mode 100644 homeassistant/components/wyoming/devices.py create mode 100644 homeassistant/components/wyoming/entity.py create mode 100644 homeassistant/components/wyoming/models.py create mode 100644 homeassistant/components/wyoming/satellite.py create mode 100644 homeassistant/components/wyoming/select.py create mode 100644 homeassistant/components/wyoming/switch.py create mode 100644 tests/components/wyoming/test_binary_sensor.py create mode 100644 tests/components/wyoming/test_devices.py create mode 100644 tests/components/wyoming/test_satellite.py create mode 100644 tests/components/wyoming/test_select.py create mode 100644 tests/components/wyoming/test_switch.py diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 33064d21097..2cc9b7050a0 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -4,17 +4,26 @@ from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService +from .devices import SatelliteDevice +from .models import DomainDataItem +from .satellite import WyomingSatellite _LOGGER = logging.getLogger(__name__) +SATELLITE_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SWITCH] + __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup_entry", + "async_unload_entry", ] @@ -25,24 +34,72 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if service is None: raise ConfigEntryNotReady("Unable to connect") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = service + item = DomainDataItem(service=service) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = item - await hass.config_entries.async_forward_entry_setups( - entry, - service.platforms, - ) + await hass.config_entries.async_forward_entry_setups(entry, service.platforms) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + if (satellite_info := service.info.satellite) is not None: + # Create satellite device, etc. + item.satellite = _make_satellite(hass, entry, service) + + # Set up satellite sensors, switches, etc. + await hass.config_entries.async_forward_entry_setups(entry, SATELLITE_PLATFORMS) + + # Start satellite communication + entry.async_create_background_task( + hass, + item.satellite.run(), + f"Satellite {satellite_info.name}", + ) + + entry.async_on_unload(item.satellite.stop) return True +def _make_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService +) -> WyomingSatellite: + """Create Wyoming satellite/device from config entry and Wyoming service.""" + satellite_info = service.info.satellite + assert satellite_info is not None + + dev_reg = dr.async_get(hass) + + # Use config entry id since only one satellite per entry is supported + satellite_id = config_entry.entry_id + + device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, satellite_id)}, + name=satellite_info.name, + suggested_area=satellite_info.area, + ) + + satellite_device = SatelliteDevice( + satellite_id=satellite_id, + device_id=device.id, + ) + + return WyomingSatellite(hass, service, satellite_device) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Wyoming.""" - service: WyomingService = hass.data[DOMAIN][entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms( - entry, - service.platforms, - ) + platforms = list(item.service.platforms) + if item.satellite is not None: + platforms += SATELLITE_PLATFORMS + + unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) if unload_ok: del hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py new file mode 100644 index 00000000000..4f2c0bb170a --- /dev/null +++ b/homeassistant/components/wyoming/binary_sensor.py @@ -0,0 +1,55 @@ +"""Binary sensor for Wyoming.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteAssistInProgress(item.satellite.device)]) + + +class WyomingSatelliteAssistInProgress(WyomingSatelliteEntity, BinarySensorEntity): + """Entity to represent Assist is in progress for satellite.""" + + entity_description = BinarySensorEntityDescription( + key="assist_in_progress", + translation_key="assist_in_progress", + ) + _attr_is_on = False + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + self._device.set_is_active_listener(self._is_active_changed) + + @callback + def _is_active_changed(self) -> None: + """Call when active state changed.""" + self._attr_is_on = self._device.is_active + self.async_write_ha_state() diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index f6b8ed73890..b766fc80c89 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -1,19 +1,22 @@ """Config flow for Wyoming integration.""" from __future__ import annotations +import logging from typing import Any from urllib.parse import urlparse import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components import hassio, zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN from .data import WyomingService +_LOGGER = logging.getLogger() + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -27,7 +30,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - _hassio_discovery: HassioServiceInfo + _hassio_discovery: hassio.HassioServiceInfo + _service: WyomingService | None = None + _name: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -50,27 +55,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors={"base": "cannot_connect"}, ) - # ASR = automated speech recognition (speech-to-text) - asr_installed = [asr for asr in service.info.asr if asr.installed] + if name := service.get_name(): + return self.async_create_entry(title=name, data=user_input) - # TTS = text-to-speech - tts_installed = [tts for tts in service.info.tts if tts.installed] + return self.async_abort(reason="no_services") - # wake-word-detection - wake_installed = [wake for wake in service.info.wake if wake.installed] - - if asr_installed: - name = asr_installed[0].name - elif tts_installed: - name = tts_installed[0].name - elif wake_installed: - name = wake_installed[0].name - else: - return self.async_abort(reason="no_services") - - return self.async_create_entry(title=name, data=user_input) - - async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: + async def async_step_hassio( + self, discovery_info: hassio.HassioServiceInfo + ) -> FlowResult: """Handle Supervisor add-on discovery.""" await self.async_set_unique_id(discovery_info.uuid) self._abort_if_unique_id_configured() @@ -93,11 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: uri = urlparse(self._hassio_discovery.config["uri"]) if service := await WyomingService.create(uri.hostname, uri.port): - if ( - not any(asr for asr in service.info.asr if asr.installed) - and not any(tts for tts in service.info.tts if tts.installed) - and not any(wake for wake in service.info.wake if wake.installed) - ): + if not service.has_services(): return self.async_abort(reason="no_services") return self.async_create_entry( @@ -112,3 +100,52 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"addon": self._hassio_discovery.name}, errors=errors, ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Discovery info: %s", discovery_info) + if discovery_info.port is None: + return self.async_abort(reason="no_port") + + service = await WyomingService.create(discovery_info.host, discovery_info.port) + if (service is None) or (not (name := service.get_name())): + # No supported services + return self.async_abort(reason="no_services") + + self._name = name + + # Use zeroconf name + service name as unique id. + # The satellite will use its own MAC as the zeroconf name by default. + unique_id = f"{discovery_info.name}_{self._name}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + self.context[CONF_NAME] = self._name + self.context["title_placeholders"] = {"name": self._name} + + self._service = service + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + assert self._service is not None + assert self._name is not None + + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self._name}, + errors={}, + ) + + return self.async_create_entry( + title=self._name, + data={ + CONF_HOST: self._service.host, + CONF_PORT: self._service.port, + }, + ) diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index 64b92eb8471..ea58181a707 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from wyoming.client import AsyncTcpClient -from wyoming.info import Describe, Info +from wyoming.info import Describe, Info, Satellite from homeassistant.const import Platform @@ -32,6 +32,43 @@ class WyomingService: platforms.append(Platform.WAKE_WORD) self.platforms = platforms + def has_services(self) -> bool: + """Return True if services are installed that Home Assistant can use.""" + return ( + any(asr for asr in self.info.asr if asr.installed) + or any(tts for tts in self.info.tts if tts.installed) + or any(wake for wake in self.info.wake if wake.installed) + or ((self.info.satellite is not None) and self.info.satellite.installed) + ) + + def get_name(self) -> str | None: + """Return name of first installed usable service.""" + # ASR = automated speech recognition (speech-to-text) + asr_installed = [asr for asr in self.info.asr if asr.installed] + if asr_installed: + return asr_installed[0].name + + # TTS = text-to-speech + tts_installed = [tts for tts in self.info.tts if tts.installed] + if tts_installed: + return tts_installed[0].name + + # wake-word-detection + wake_installed = [wake for wake in self.info.wake if wake.installed] + if wake_installed: + return wake_installed[0].name + + # satellite + satellite_installed: Satellite | None = None + + if (self.info.satellite is not None) and self.info.satellite.installed: + satellite_installed = self.info.satellite + + if satellite_installed: + return satellite_installed.name + + return None + @classmethod async def create(cls, host: str, port: int) -> WyomingService | None: """Create a Wyoming service.""" diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py new file mode 100644 index 00000000000..90dad889707 --- /dev/null +++ b/homeassistant/components/wyoming/devices.py @@ -0,0 +1,85 @@ +"""Class to manage satellite devices.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN + + +@dataclass +class SatelliteDevice: + """Class to store device.""" + + satellite_id: str + device_id: str + is_active: bool = False + is_enabled: bool = True + pipeline_name: str | None = None + + _is_active_listener: Callable[[], None] | None = None + _is_enabled_listener: Callable[[], None] | None = None + _pipeline_listener: Callable[[], None] | None = None + + @callback + def set_is_active(self, active: bool) -> None: + """Set active state.""" + if active != self.is_active: + self.is_active = active + if self._is_active_listener is not None: + self._is_active_listener() + + @callback + def set_is_enabled(self, enabled: bool) -> None: + """Set enabled state.""" + if enabled != self.is_enabled: + self.is_enabled = enabled + if self._is_enabled_listener is not None: + self._is_enabled_listener() + + @callback + def set_pipeline_name(self, pipeline_name: str) -> None: + """Inform listeners that pipeline selection has changed.""" + if pipeline_name != self.pipeline_name: + self.pipeline_name = pipeline_name + if self._pipeline_listener is not None: + self._pipeline_listener() + + @callback + def set_is_active_listener(self, is_active_listener: Callable[[], None]) -> None: + """Listen for updates to is_active.""" + self._is_active_listener = is_active_listener + + @callback + def set_is_enabled_listener(self, is_enabled_listener: Callable[[], None]) -> None: + """Listen for updates to is_enabled.""" + self._is_enabled_listener = is_enabled_listener + + @callback + def set_pipeline_listener(self, pipeline_listener: Callable[[], None]) -> None: + """Listen for updates to pipeline.""" + self._pipeline_listener = pipeline_listener + + def get_assist_in_progress_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for assist in progress binary sensor.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "binary_sensor", DOMAIN, f"{self.satellite_id}-assist_in_progress" + ) + + def get_satellite_enabled_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for satellite enabled switch.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "switch", DOMAIN, f"{self.satellite_id}-satellite_enabled" + ) + + def get_pipeline_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for pipeline select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-pipeline" + ) diff --git a/homeassistant/components/wyoming/entity.py b/homeassistant/components/wyoming/entity.py new file mode 100644 index 00000000000..5ed890bc60e --- /dev/null +++ b/homeassistant/components/wyoming/entity.py @@ -0,0 +1,24 @@ +"""Wyoming entities.""" + +from __future__ import annotations + +from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN +from .satellite import SatelliteDevice + + +class WyomingSatelliteEntity(entity.Entity): + """Wyoming satellite entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, device: SatelliteDevice) -> None: + """Initialize entity.""" + self._device = device + self._attr_unique_id = f"{device.satellite_id}-{self.entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.satellite_id)}, + ) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index ddb5407e1ce..540aaa9aeac 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -3,7 +3,9 @@ "name": "Wyoming Protocol", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, + "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.2.0"] + "requirements": ["wyoming==1.3.0"], + "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/models.py b/homeassistant/components/wyoming/models.py new file mode 100644 index 00000000000..dce45d509eb --- /dev/null +++ b/homeassistant/components/wyoming/models.py @@ -0,0 +1,13 @@ +"""Models for wyoming.""" +from dataclasses import dataclass + +from .data import WyomingService +from .satellite import WyomingSatellite + + +@dataclass +class DomainDataItem: + """Domain data item.""" + + service: WyomingService + satellite: WyomingSatellite | None = None diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py new file mode 100644 index 00000000000..caf65db115e --- /dev/null +++ b/homeassistant/components/wyoming/satellite.py @@ -0,0 +1,380 @@ +"""Support for Wyoming satellite services.""" +import asyncio +from collections.abc import AsyncGenerator +import io +import logging +from typing import Final +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize, SynthesizeVoice +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, stt, tts +from homeassistant.components.assist_pipeline import select as pipeline_select +from homeassistant.core import Context, HomeAssistant + +from .const import DOMAIN +from .data import WyomingService +from .devices import SatelliteDevice + +_LOGGER = logging.getLogger() + +_SAMPLES_PER_CHUNK: Final = 1024 +_RECONNECT_SECONDS: Final = 10 +_RESTART_SECONDS: Final = 3 + +# Wyoming stage -> Assist stage +_STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { + PipelineStage.WAKE: assist_pipeline.PipelineStage.WAKE_WORD, + PipelineStage.ASR: assist_pipeline.PipelineStage.STT, + PipelineStage.HANDLE: assist_pipeline.PipelineStage.INTENT, + PipelineStage.TTS: assist_pipeline.PipelineStage.TTS, +} + + +class WyomingSatellite: + """Remove voice satellite running the Wyoming protocol.""" + + def __init__( + self, hass: HomeAssistant, service: WyomingService, device: SatelliteDevice + ) -> None: + """Initialize satellite.""" + self.hass = hass + self.service = service + self.device = device + self.is_enabled = True + self.is_running = True + + self._client: AsyncTcpClient | None = None + self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1) + self._is_pipeline_running = False + self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() + self._pipeline_id: str | None = None + self._enabled_changed_event = asyncio.Event() + + self.device.set_is_enabled_listener(self._enabled_changed) + self.device.set_pipeline_listener(self._pipeline_changed) + + async def run(self) -> None: + """Run and maintain a connection to satellite.""" + _LOGGER.debug("Running satellite task") + + try: + while self.is_running: + try: + # Check if satellite has been disabled + if not self.device.is_enabled: + await self.on_disabled() + if not self.is_running: + # Satellite was stopped while waiting to be enabled + break + + # Connect and run pipeline loop + await self._run_once() + except asyncio.CancelledError: + raise + except Exception: # pylint: disable=broad-exception-caught + await self.on_restart() + finally: + # Ensure sensor is off + self.device.set_is_active(False) + + await self.on_stopped() + + def stop(self) -> None: + """Signal satellite task to stop running.""" + self.is_running = False + + # Unblock waiting for enabled + self._enabled_changed_event.set() + + async def on_restart(self) -> None: + """Block until pipeline loop will be restarted.""" + _LOGGER.warning( + "Unexpected error running satellite. Restarting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RESTART_SECONDS) + + async def on_reconnect(self) -> None: + """Block until a reconnection attempt should be made.""" + _LOGGER.debug( + "Failed to connect to satellite. Reconnecting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RECONNECT_SECONDS) + + async def on_disabled(self) -> None: + """Block until device may be enabled again.""" + await self._enabled_changed_event.wait() + + async def on_stopped(self) -> None: + """Run when run() has fully stopped.""" + _LOGGER.debug("Satellite task stopped") + + # ------------------------------------------------------------------------- + + def _enabled_changed(self) -> None: + """Run when device enabled status changes.""" + + if not self.device.is_enabled: + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + self._enabled_changed_event.set() + + def _pipeline_changed(self) -> None: + """Run when device pipeline changes.""" + + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + async def _run_once(self) -> None: + """Run pipelines until an error occurs.""" + self.device.set_is_active(False) + + while self.is_running and self.is_enabled: + try: + await self._connect() + break + except ConnectionError: + await self.on_reconnect() + + assert self._client is not None + _LOGGER.debug("Connected to satellite") + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled during connection + return + + # Tell satellite that we're ready + await self._client.write_event(RunSatellite().event()) + + # Wait until we get RunPipeline event + run_pipeline: RunPipeline | None = None + while self.is_running and self.is_enabled: + run_event = await self._client.read_event() + if run_event is None: + raise ConnectionResetError("Satellite disconnected") + + if RunPipeline.is_type(run_event.type): + run_pipeline = RunPipeline.from_event(run_event) + break + + _LOGGER.debug("Unexpected event from satellite: %s", run_event) + + assert run_pipeline is not None + _LOGGER.debug("Received run information: %s", run_pipeline) + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled while waiting for + # RunPipeline event. + return + + start_stage = _STAGES.get(run_pipeline.start_stage) + end_stage = _STAGES.get(run_pipeline.end_stage) + + if start_stage is None: + raise ValueError(f"Invalid start stage: {start_stage}") + + if end_stage is None: + raise ValueError(f"Invalid end stage: {end_stage}") + + # Each loop is a pipeline run + while self.is_running and self.is_enabled: + # Use select to get pipeline each time in case it's changed + pipeline_id = pipeline_select.get_chosen_pipeline( + self.hass, + DOMAIN, + self.device.satellite_id, + ) + pipeline = assist_pipeline.async_get_pipeline(self.hass, pipeline_id) + assert pipeline is not None + + # We will push audio in through a queue + self._audio_queue = asyncio.Queue() + stt_stream = self._stt_stream() + + # Start pipeline running + _LOGGER.debug( + "Starting pipeline %s from %s to %s", + pipeline.name, + start_stage, + end_stage, + ) + self._is_pipeline_running = True + _pipeline_task = asyncio.create_task( + assist_pipeline.async_pipeline_from_audio_stream( + self.hass, + context=Context(), + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language=pipeline.language, + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=stt_stream, + start_stage=start_stage, + end_stage=end_stage, + tts_audio_output="wav", + pipeline_id=pipeline_id, + ) + ) + + # Run until pipeline is complete or cancelled with an empty audio chunk + while self._is_pipeline_running: + client_event = await self._client.read_event() + if client_event is None: + raise ConnectionResetError("Satellite disconnected") + + if AudioChunk.is_type(client_event.type): + # Microphone audio + chunk = AudioChunk.from_event(client_event) + chunk = self._chunk_converter.convert(chunk) + self._audio_queue.put_nowait(chunk.audio) + else: + _LOGGER.debug("Unexpected event from satellite: %s", client_event) + + _LOGGER.debug("Pipeline finished") + + def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: + """Translate pipeline events into Wyoming events.""" + assert self._client is not None + + if event.type == assist_pipeline.PipelineEventType.RUN_END: + # Pipeline run is complete + self._is_pipeline_running = False + self.device.set_is_active(False) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: + self.hass.add_job(self._client.write_event(Detect().event())) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END: + # Wake word detection + self.device.set_is_active(True) + + # Inform client of wake word detection + if event.data and (wake_word_output := event.data.get("wake_word_output")): + detection = Detection( + name=wake_word_output["wake_word_id"], + timestamp=wake_word_output.get("timestamp"), + ) + self.hass.add_job(self._client.write_event(detection.event())) + elif event.type == assist_pipeline.PipelineEventType.STT_START: + # Speech-to-text + self.device.set_is_active(True) + + if event.data: + self.hass.add_job( + self._client.write_event( + Transcribe(language=event.data["metadata"]["language"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_START: + # User started speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStarted(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_END: + # User stopped speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStopped(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_END: + # Speech-to-text transcript + if event.data: + # Inform client of transript + stt_text = event.data["stt_output"]["text"] + self.hass.add_job( + self._client.write_event(Transcript(text=stt_text).event()) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_START: + # Text-to-speech text + if event.data: + # Inform client of text + self.hass.add_job( + self._client.write_event( + Synthesize( + text=event.data["tts_input"], + voice=SynthesizeVoice( + name=event.data.get("voice"), + language=event.data.get("language"), + ), + ).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_END: + # TTS stream + if event.data and (tts_output := event.data["tts_output"]): + media_id = tts_output["media_id"] + self.hass.add_job(self._stream_tts(media_id)) + + async def _connect(self) -> None: + """Connect to satellite over TCP.""" + _LOGGER.debug( + "Connecting to satellite at %s:%s", self.service.host, self.service.port + ) + self._client = AsyncTcpClient(self.service.host, self.service.port) + await self._client.connect() + + async def _stream_tts(self, media_id: str) -> None: + """Stream TTS WAV audio to satellite in chunks.""" + assert self._client is not None + + extension, data = await tts.async_get_media_source_audio(self.hass, media_id) + if extension != "wav": + raise ValueError(f"Cannot stream audio format to satellite: {extension}") + + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + + timestamp = 0 + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() + ) + + # Stream audio chunks + while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): + chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=audio_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + timestamp += chunk.seconds + + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") + + async def _stt_stream(self) -> AsyncGenerator[bytes, None]: + """Yield audio chunks from a queue.""" + is_first_chunk = True + while chunk := await self._audio_queue.get(): + if is_first_chunk: + is_first_chunk = False + _LOGGER.debug("Receiving audio from satellite") + + yield chunk diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py new file mode 100644 index 00000000000..2929ae79fa0 --- /dev/null +++ b/homeassistant/components/wyoming/select.py @@ -0,0 +1,47 @@ +"""Select entities for VoIP integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import SatelliteDevice +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatellitePipelineSelect(hass, item.satellite.device)]) + + +class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelect): + """Pipeline selector for Wyoming satellites.""" + + def __init__(self, hass: HomeAssistant, device: SatelliteDevice) -> None: + """Initialize a pipeline selector.""" + self.device = device + + WyomingSatelliteEntity.__init__(self, device) + AssistPipelineSelect.__init__(self, hass, device.satellite_id) + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + await super().async_select_option(option) + self.device.set_pipeline_name(option) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 20d73d8dc13..19b6a513d4b 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -9,6 +9,10 @@ }, "hassio_confirm": { "description": "Do you want to configure Home Assistant to connect to the Wyoming service provided by the add-on: {addon}?" + }, + "zeroconf_confirm": { + "description": "Do you want to configure Home Assistant to connect to the Wyoming service {name}?", + "title": "Discovered Wyoming service" } }, "error": { @@ -16,7 +20,31 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_services": "No services found at endpoint" + "no_services": "No services found at endpoint", + "no_port": "No port for endpoint" + } + }, + "entity": { + "binary_sensor": { + "assist_in_progress": { + "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + }, + "noise_suppression": { + "name": "Noise suppression" + } + }, + "switch": { + "satellite_enabled": { + "name": "Satellite enabled" + } } } } diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index e64a2f14667..8a21ef051fc 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -24,10 +25,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingSttProvider(config_entry, service), + WyomingSttProvider(config_entry, item.service), ] ) diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py new file mode 100644 index 00000000000..2bc43122588 --- /dev/null +++ b/homeassistant/components/wyoming/switch.py @@ -0,0 +1,65 @@ +"""Wyoming switch entities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteEnabledSwitch(item.satellite.device)]) + + +class WyomingSatelliteEnabledSwitch( + WyomingSatelliteEntity, restore_state.RestoreEntity, SwitchEntity +): + """Entity to represent if satellite is enabled.""" + + entity_description = SwitchEntityDescription( + key="satellite_enabled", + translation_key="satellite_enabled", + entity_category=EntityCategory.CONFIG, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + + # Default to on + self._attr_is_on = (state is None) or (state.state == STATE_ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + self._device.set_is_enabled(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + self._attr_is_on = False + self.async_write_ha_state() + self._device.set_is_enabled(False) diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index cde771cd330..f024f925514 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -16,6 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -26,10 +27,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingTtsProvider(config_entry, service), + WyomingTtsProvider(config_entry, item.service), ] ) diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index fce8bbf6327..da05e8c9fe1 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import WyomingService, load_wyoming_info from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -25,10 +26,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming wake-word-detection.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingWakeWordProvider(hass, config_entry, service), + WyomingWakeWordProvider(hass, config_entry, item.service), ] ) diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e8d117d1f33..55570078d80 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -715,6 +715,11 @@ ZEROCONF = { "domain": "wled", }, ], + "_wyoming._tcp.local.": [ + { + "domain": "wyoming", + }, + ], "_xbmc-jsonrpc-h._tcp.local.": [ { "domain": "kodi", diff --git a/requirements_all.txt b/requirements_all.txt index 3b916366478..de4086b5fb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2750,7 +2750,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.3.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2194cd6e79..a25d5b3c566 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2054,7 +2054,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.3.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index e04ff4eda03..899eda7ec1a 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,11 +1,13 @@ """Tests for the Wyoming integration.""" import asyncio +from wyoming.event import Event from wyoming.info import ( AsrModel, AsrProgram, Attribution, Info, + Satellite, TtsProgram, TtsVoice, TtsVoiceSpeaker, @@ -72,24 +74,36 @@ WAKE_WORD_INFO = Info( ) ] ) +SATELLITE_INFO = Info( + satellite=Satellite( + name="Test Satellite", + description="Test Satellite", + installed=True, + attribution=TEST_ATTR, + area="Office", + ) +) EMPTY_INFO = Info() class MockAsyncTcpClient: """Mock AsyncTcpClient.""" - def __init__(self, responses) -> None: + def __init__(self, responses: list[Event]) -> None: """Initialize.""" - self.host = None - self.port = None - self.written = [] + self.host: str | None = None + self.port: int | None = None + self.written: list[Event] = [] self.responses = responses - async def write_event(self, event): + async def connect(self) -> None: + """Connect.""" + + async def write_event(self, event: Event): """Send.""" self.written.append(event) - async def read_event(self): + async def read_event(self) -> Event | None: """Receive.""" await asyncio.sleep(0) # force context switch @@ -105,7 +119,7 @@ class MockAsyncTcpClient: async def __aexit__(self, exc_type, exc, tb): """Exit.""" - def __call__(self, host, port): + def __call__(self, host: str, port: int): """Call.""" self.host = host self.port = port diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 2c8081908f7..a30c1048eb6 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -5,14 +5,23 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components import stt +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component -from . import STT_INFO, TTS_INFO, WAKE_WORD_INFO +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def init_components(hass: HomeAssistant): + """Set up required components.""" + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" @@ -110,3 +119,39 @@ def metadata(hass: HomeAssistant) -> stt.SpeechMetadata: sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ) + + +@pytest.fixture +def satellite_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Create a config entry.""" + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_satellite(hass: HomeAssistant, satellite_config_entry: ConfigEntry): + """Initialize Wyoming satellite.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.run" + ) as _run_mock: + # _run_mock: satellite task does not actually run + await hass.config_entries.async_setup(satellite_config_entry.entry_id) + + +@pytest.fixture +async def satellite_device( + hass: HomeAssistant, init_satellite, satellite_config_entry: ConfigEntry +) -> SatelliteDevice: + """Get a satellite device fixture.""" + return hass.data[DOMAIN][satellite_config_entry.entry_id].satellite.device diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index d4220a39724..99f411027f5 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -121,3 +121,45 @@ 'version': 1, }) # --- +# name: test_zeroconf_discovery + FlowResultSnapshot({ + 'context': dict({ + 'name': 'Test Satellite', + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'Test Satellite', + }), + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'wyoming', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'disabled_by': None, + 'domain': 'wyoming', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'Test Satellite', + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + 'version': 1, + }), + 'title': 'Test Satellite', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py new file mode 100644 index 00000000000..27294186a90 --- /dev/null +++ b/tests/components/wyoming/test_binary_sensor.py @@ -0,0 +1,34 @@ +"""Test Wyoming binary sensor devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_assist_in_progress( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test assist in progress.""" + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active + + satellite_device.set_is_active(True) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_active + + satellite_device.set_is_active(False) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py index 896d3748ebd..f711b56b3bc 100644 --- a/tests/components/wyoming/test_config_flow.py +++ b/tests/components/wyoming/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Wyoming config flow.""" +from ipaddress import IPv4Address from unittest.mock import AsyncMock, patch import pytest @@ -8,10 +9,11 @@ from wyoming.info import Info from homeassistant import config_entries from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.wyoming.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import EMPTY_INFO, STT_INFO, TTS_INFO +from . import EMPTY_INFO, SATELLITE_INFO, STT_INFO, TTS_INFO from tests.common import MockConfigEntry @@ -25,6 +27,16 @@ ADDON_DISCOVERY = HassioServiceInfo( uuid="1234", ) +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=IPv4Address("127.0.0.1"), + ip_addresses=[IPv4Address("127.0.0.1")], + port=12345, + hostname="localhost", + type="_wyoming._tcp.local.", + name="test_zeroconf_name._wyoming._tcp.local.", + properties={}, +) + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -214,3 +226,70 @@ async def test_hassio_addon_no_supported_services(hass: HomeAssistant) -> None: assert result2.get("type") == FlowResultType.ABORT assert result2.get("reason") == "no_services" + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config flow initiated by Supervisor.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("description_placeholders") == { + "name": SATELLITE_INFO.satellite.name + } + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +async def test_zeroconf_discovery_no_port( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when the zeroconf service does not have a port.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch.object(ZEROCONF_DISCOVERY, "port", None): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_port" + + +async def test_zeroconf_discovery_no_services( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when there are no supported services on the client.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=Info(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_services" diff --git a/tests/components/wyoming/test_data.py b/tests/components/wyoming/test_data.py index 0cb878c39c1..b7de9dbfdc1 100644 --- a/tests/components/wyoming/test_data.py +++ b/tests/components/wyoming/test_data.py @@ -3,13 +3,15 @@ from __future__ import annotations from unittest.mock import patch -from homeassistant.components.wyoming.data import load_wyoming_info +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.wyoming.data import WyomingService, load_wyoming_info from homeassistant.core import HomeAssistant -from . import STT_INFO, MockAsyncTcpClient +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO, MockAsyncTcpClient -async def test_load_info(hass: HomeAssistant, snapshot) -> None: +async def test_load_info(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test loading info.""" with patch( "homeassistant.components.wyoming.data.AsyncTcpClient", @@ -38,3 +40,38 @@ async def test_load_info_oserror(hass: HomeAssistant) -> None: ) assert info is None + + +async def test_service_name(hass: HomeAssistant) -> None: + """Test loading service info.""" + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([STT_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == STT_INFO.asr[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([TTS_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == TTS_INFO.tts[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([WAKE_WORD_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == WAKE_WORD_INFO.wake[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([SATELLITE_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == SATELLITE_INFO.satellite.name diff --git a/tests/components/wyoming/test_devices.py b/tests/components/wyoming/test_devices.py new file mode 100644 index 00000000000..549f76f20f1 --- /dev/null +++ b/tests/components/wyoming/test_devices.py @@ -0,0 +1,78 @@ +"""Test Wyoming devices.""" +from __future__ import annotations + +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +async def test_device_registry_info( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + satellite_config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry.""" + + # Satellite uses config entry id since only one satellite per entry is + # supported. + device = device_registry.async_get_device( + identifiers={(DOMAIN, satellite_config_entry.entry_id)} + ) + assert device is not None + assert device.name == "Test Satellite" + assert device.suggested_area == "Office" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assist_in_progress_state = hass.states.get(assist_in_progress_id) + assert assist_in_progress_state is not None + assert assist_in_progress_state.state == STATE_OFF + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + satellite_enabled_state = hass.states.get(satellite_enabled_id) + assert satellite_enabled_state is not None + assert satellite_enabled_state.state == STATE_ON + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + pipeline_state = hass.states.get(pipeline_entity_id) + assert pipeline_state is not None + assert pipeline_state.state == OPTION_PREFERRED + + +async def test_remove_device_registry_entry( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing a device registry entry.""" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assert hass.states.get(assist_in_progress_id) is not None + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + assert hass.states.get(satellite_enabled_id) is not None + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + assert hass.states.get(pipeline_entity_id) is not None + + # Remove + device_registry.async_remove_device(satellite_device.device_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Everything should be gone + assert hass.states.get(assist_in_progress_id) is None + assert hass.states.get(satellite_enabled_id) is None + assert hass.states.get(pipeline_entity_id) is None diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py new file mode 100644 index 00000000000..06ae337a19c --- /dev/null +++ b/tests/components/wyoming/test_satellite.py @@ -0,0 +1,460 @@ +"""Test Wyoming satellite.""" +from __future__ import annotations + +import asyncio +import io +from unittest.mock import patch +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.event import Event +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, wyoming +from homeassistant.components.wyoming.data import WyomingService +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import SATELLITE_INFO, MockAsyncTcpClient + +from tests.common import MockConfigEntry + + +async def setup_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up config entry for Wyoming satellite. + + This is separated from the satellite_config_entry method in conftest.py so + we can patch functions before the satellite task is run during setup. + """ + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +def get_test_wav() -> bytes: + """Get bytes for test WAV file.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + + # Single frame + wav_file.writeframes(b"123") + + return wav_io.getvalue() + + +class SatelliteAsyncTcpClient(MockAsyncTcpClient): + """Satellite AsyncTcpClient.""" + + def __init__(self, responses: list[Event]) -> None: + """Initialize client.""" + super().__init__(responses) + + self.connect_event = asyncio.Event() + self.run_satellite_event = asyncio.Event() + self.detect_event = asyncio.Event() + + self.detection_event = asyncio.Event() + self.detection: Detection | None = None + + self.transcribe_event = asyncio.Event() + self.transcribe: Transcribe | None = None + + self.voice_started_event = asyncio.Event() + self.voice_started: VoiceStarted | None = None + + self.voice_stopped_event = asyncio.Event() + self.voice_stopped: VoiceStopped | None = None + + self.transcript_event = asyncio.Event() + self.transcript: Transcript | None = None + + self.synthesize_event = asyncio.Event() + self.synthesize: Synthesize | None = None + + self.tts_audio_start_event = asyncio.Event() + self.tts_audio_chunk_event = asyncio.Event() + self.tts_audio_stop_event = asyncio.Event() + self.tts_audio_chunk: AudioChunk | None = None + + self._mic_audio_chunk = AudioChunk( + rate=16000, width=2, channels=1, audio=b"chunk" + ).event() + + async def connect(self) -> None: + """Connect.""" + self.connect_event.set() + + async def write_event(self, event: Event): + """Send.""" + if RunSatellite.is_type(event.type): + self.run_satellite_event.set() + elif Detect.is_type(event.type): + self.detect_event.set() + elif Detection.is_type(event.type): + self.detection = Detection.from_event(event) + self.detection_event.set() + elif Transcribe.is_type(event.type): + self.transcribe = Transcribe.from_event(event) + self.transcribe_event.set() + elif VoiceStarted.is_type(event.type): + self.voice_started = VoiceStarted.from_event(event) + self.voice_started_event.set() + elif VoiceStopped.is_type(event.type): + self.voice_stopped = VoiceStopped.from_event(event) + self.voice_stopped_event.set() + elif Transcript.is_type(event.type): + self.transcript = Transcript.from_event(event) + self.transcript_event.set() + elif Synthesize.is_type(event.type): + self.synthesize = Synthesize.from_event(event) + self.synthesize_event.set() + elif AudioStart.is_type(event.type): + self.tts_audio_start_event.set() + elif AudioChunk.is_type(event.type): + self.tts_audio_chunk = AudioChunk.from_event(event) + self.tts_audio_chunk_event.set() + elif AudioStop.is_type(event.type): + self.tts_audio_stop_event.set() + + async def read_event(self) -> Event | None: + """Receive.""" + event = await super().read_event() + + # Keep sending audio chunks instead of None + return event or self._mic_audio_chunk + + +async def test_satellite_pipeline(hass: HomeAssistant) -> None: + """Test running a pipeline with a satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.tts.async_get_media_source_audio", + return_value=("wav", get_test_wav()), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + + # Start detecting wake word + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_START + ) + ) + async with asyncio.timeout(1): + await mock_client.detect_event.wait() + + assert not device.is_active + assert device.is_enabled + + # Wake word is detected + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_END, + {"wake_word_output": {"wake_word_id": "test_wake_word"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.detection_event.wait() + + assert mock_client.detection is not None + assert mock_client.detection.name == "test_wake_word" + + # "Assist in progress" sensor should be active now + assert device.is_active + + # Speech-to-text started + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_START, + {"metadata": {"language": "en"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcribe_event.wait() + + assert mock_client.transcribe is not None + assert mock_client.transcribe.language == "en" + + # User started speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_START, {"timestamp": 1234} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_started_event.wait() + + assert mock_client.voice_started is not None + assert mock_client.voice_started.timestamp == 1234 + + # User stopped speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_END, {"timestamp": 5678} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_stopped_event.wait() + + assert mock_client.voice_stopped is not None + assert mock_client.voice_stopped.timestamp == 5678 + + # Speech-to-text transcription + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_END, + {"stt_output": {"text": "test transcript"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcript_event.wait() + + assert mock_client.transcript is not None + assert mock_client.transcript.text == "test transcript" + + # Text-to-speech text + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + async with asyncio.timeout(1): + await mock_client.synthesize_event.wait() + + assert mock_client.synthesize is not None + assert mock_client.synthesize.text == "test text to speak" + assert mock_client.synthesize.voice is not None + assert mock_client.synthesize.voice.name == "test voice" + + # Text-to-speech media + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"media_id": "test media id"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.tts_audio_start_event.wait() + await mock_client.tts_audio_chunk_event.wait() + await mock_client.tts_audio_stop_event.wait() + + # Verify audio chunk from test WAV + assert mock_client.tts_audio_chunk is not None + assert mock_client.tts_audio_chunk.rate == 22050 + assert mock_client.tts_audio_chunk.width == 2 + assert mock_client.tts_audio_chunk.channels == 1 + assert mock_client.tts_audio_chunk.audio == b"123" + + # Pipeline finished + event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + assert not device.is_active + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_satellite_disabled(hass: HomeAssistant) -> None: + """Test callback for a satellite that has been disabled.""" + on_disabled_event = asyncio.Event() + + original_make_satellite = wyoming._make_satellite + + def make_disabled_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService + ): + satellite = original_make_satellite(hass, config_entry, service) + satellite.device.is_enabled = False + + return satellite + + async def on_disabled(self): + on_disabled_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming._make_satellite", make_disabled_satellite + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_disabled", + on_disabled, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_disabled_event.wait() + + +async def test_satellite_restart(hass: HomeAssistant) -> None: + """Test pipeline loop restart after unexpected error.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite._run_once", + side_effect=RuntimeError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + +async def test_satellite_reconnect(hass: HomeAssistant) -> None: + """Test satellite reconnect call after connection refused.""" + on_reconnect_event = asyncio.Event() + + async def on_reconnect(self): + self.stop() + on_reconnect_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient.connect", + side_effect=ConnectionRefusedError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_reconnect", + on_reconnect, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_reconnect_event.wait() + + +async def test_satellite_disconnect_before_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting before pipeline run.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient([]), # no RunPipeline event + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + # Pipeline should never have run + mock_run_pipeline.assert_not_called() + + +async def test_satellite_disconnect_during_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting during pipeline run.""" + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] # no audio chunks after RunPipeline + + on_restart_event = asyncio.Event() + on_stopped_event = asyncio.Event() + + async def on_restart(self): + # Pretend sensor got stuck on + self.device.is_active = True + self.stop() + on_restart_event.set() + + async def on_stopped(self): + on_stopped_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient(events), + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped", + on_stopped, + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await on_restart_event.wait() + await on_stopped_event.wait() + + # Pipeline should have run once + mock_run_pipeline.assert_called_once() + + # Sensor should have been turned off + assert not device.is_active diff --git a/tests/components/wyoming/test_select.py b/tests/components/wyoming/test_select.py new file mode 100644 index 00000000000..cab699336fb --- /dev/null +++ b/tests/components/wyoming/test_select.py @@ -0,0 +1,83 @@ +"""Test Wyoming select.""" +from unittest.mock import Mock, patch + +from homeassistant.components import assist_pipeline +from homeassistant.components.assist_pipeline.pipeline import PipelineData +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_pipeline_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test pipeline select. + + Functionality is tested in assist_pipeline/test_select.py. + This test is only to ensure it is set up. + """ + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + pipeline_data: PipelineData = hass.data[assist_pipeline.DOMAIN] + + # Create second pipeline + await pipeline_data.pipeline_store.async_create_item( + { + "name": "Test 1", + "language": "en-US", + "conversation_engine": None, + "conversation_language": "en-US", + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "stt_engine": None, + "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + + # Preferred pipeline is the default + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # Change to second pipeline + with patch.object(satellite_device, "set_pipeline_name") as mock_pipeline_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": "Test 1"}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "Test 1" + + # async_pipeline_changed should have been called + mock_pipeline_changed.assert_called_once_with("Test 1") + + # Change back and check update listener + pipeline_listener = Mock() + satellite_device.set_pipeline_listener(pipeline_listener) + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": OPTION_PREFERRED}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # listener should have been called + pipeline_listener.assert_called_once() diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py new file mode 100644 index 00000000000..0b05724d761 --- /dev/null +++ b/tests/components/wyoming/test_switch.py @@ -0,0 +1,32 @@ +"""Test Wyoming switch devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_satellite_enabled( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test satellite enabled.""" + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_enabled + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": satellite_enabled_id}, + blocking=True, + ) + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_enabled From 99401c60c7883a1ed8461d657091339643582803 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 4 Dec 2023 17:21:41 +0100 Subject: [PATCH 487/643] Add Matter custom cluster sensors (Eve Energy Plug energy measurements) (#104830) * Support for sensors from custom clusters in Matter * lint * no need to write state twice * Add test for eve energy plug * Update homeassistant/components/matter/entity.py Co-authored-by: Martin Hjelmare * adjust comment * debounce extra poll timer * use async_call_later helper * Update homeassistant/components/matter/entity.py Co-authored-by: Martin Hjelmare * wip extend test * Update test_sensor.py * fix state class for sensors * trigger (fake) event callback on all subscribers * Update eve-energy-plug.json * add test for additionally polled value * adjust delay to 3 seconds * Adjust subscribe_events to always use kwargs * Update tests/components/matter/common.py Co-authored-by: Martin Hjelmare * Update test_sensor.py * remove redundant code --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/adapter.py | 9 +- homeassistant/components/matter/discovery.py | 5 +- homeassistant/components/matter/entity.py | 42 +- homeassistant/components/matter/models.py | 6 + homeassistant/components/matter/sensor.py | 74 +- tests/components/matter/common.py | 8 +- .../fixtures/nodes/eve-energy-plug.json | 649 ++++++++++++++++++ tests/components/matter/test_adapter.py | 7 +- tests/components/matter/test_sensor.py | 82 ++- 9 files changed, 867 insertions(+), 15 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/eve-energy-plug.json diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 2831ebe9a38..5690996841d 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -97,22 +97,23 @@ class MatterAdapter: self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_added_callback, EventType.ENDPOINT_ADDED + callback=endpoint_added_callback, event_filter=EventType.ENDPOINT_ADDED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_removed_callback, EventType.ENDPOINT_REMOVED + callback=endpoint_removed_callback, + event_filter=EventType.ENDPOINT_REMOVED, ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_removed_callback, EventType.NODE_REMOVED + callback=node_removed_callback, event_filter=EventType.NODE_REMOVED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_added_callback, EventType.NODE_ADDED + callback=node_added_callback, event_filter=EventType.NODE_ADDED ) ) diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index c971bf8465e..e1d004a15c8 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -115,8 +115,9 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, + should_poll=schema.should_poll, ) - # prevent re-discovery of the same attributes + # prevent re-discovery of the primary attribute if not allowed if not schema.allow_multi: - discovered_attributes.update(attributes_to_watch) + discovered_attributes.update(schema.required_attributes) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 7e7b7a688df..de6e6ff83c2 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -5,6 +5,7 @@ from abc import abstractmethod from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass +from datetime import datetime import logging from typing import TYPE_CHECKING, Any, cast @@ -12,9 +13,10 @@ from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.event import async_call_later from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -27,6 +29,13 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +# For some manually polled values (e.g. custom clusters) we perform +# an additional poll as soon as a secondary value changes. +# For example update the energy consumption meter when a relay is toggled +# of an energy metering powerplug. The below constant defined the delay after +# which we poll the primary value (debounced). +EXTRA_POLL_DELAY = 3.0 + @dataclass class MatterEntityDescription(EntityDescription): @@ -39,7 +48,6 @@ class MatterEntityDescription(EntityDescription): class MatterEntity(Entity): """Entity class for Matter devices.""" - _attr_should_poll = False _attr_has_entity_name = True def __init__( @@ -71,6 +79,8 @@ class MatterEntity(Entity): identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} ) self._attr_available = self._endpoint.node.available + self._attr_should_poll = entity_info.should_poll + self._extra_poll_timer_unsub: CALLBACK_TYPE | None = None async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -110,15 +120,35 @@ class MatterEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() for unsub in self._unsubscribes: with suppress(ValueError): # suppress ValueError to prevent race conditions unsub() + async def async_update(self) -> None: + """Call when the entity needs to be updated.""" + # manually poll/refresh the primary value + await self.matter_client.refresh_attribute( + self._endpoint.node.node_id, + self.get_matter_attribute_path(self._entity_info.primary_attribute), + ) + self._update_from_device() + @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: - """Call on update.""" + """Call on update from the device.""" self._attr_available = self._endpoint.node.available + if self._attr_should_poll: + # secondary attribute updated of a polled primary value + # enforce poll of the primary value a few seconds later + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() + self._extra_poll_timer_unsub = async_call_later( + self.hass, EXTRA_POLL_DELAY, self._do_extra_poll + ) + return self._update_from_device() self.async_write_ha_state() @@ -145,3 +175,9 @@ class MatterEntity(Entity): return create_attribute_path( self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id ) + + @callback + def _do_extra_poll(self, called_at: datetime) -> None: + """Perform (extra) poll of primary value.""" + # scheduling the regulat update is enough to perform a poll/refresh + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 34447751797..5f47f73b139 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -50,6 +50,9 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type + # [optional] bool to specify if this primary value should be polled + should_poll: bool + @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -106,3 +109,6 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False + + # [optional] bool to specify if this primary value should be polled + should_poll: bool = False diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 5021ed7fa0d..6262eb253aa 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue +from matter_server.client.models.clusters import EveEnergyCluster from homeassistant.components.sensor import ( SensorDeviceClass, @@ -18,6 +19,10 @@ from homeassistant.const import ( PERCENTAGE, EntityCategory, Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, UnitOfPressure, UnitOfTemperature, UnitOfVolumeFlowRate, @@ -48,7 +53,6 @@ class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescrip class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" - _attr_state_class = SensorStateClass.MEASUREMENT entity_description: MatterSensorEntityDescription @callback @@ -72,6 +76,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), @@ -83,6 +88,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), @@ -94,6 +100,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, translation_key="flow", measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), @@ -105,6 +112,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=( @@ -118,6 +126,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), @@ -131,8 +140,71 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, # value has double precision measurement_to_ha=lambda x: int(x / 2), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWatt", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Watt,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorVoltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Voltage,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattAccumulated", + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=3, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.WattAccumulated,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattCurrent", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Current,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), ] diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index a0935154054..d5093367db5 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -71,6 +71,10 @@ async def trigger_subscription_callback( data: Any = None, ) -> None: """Trigger a subscription callback.""" - callback = client.subscribe_events.call_args.kwargs["callback"] - callback(event, data) + # trigger callback on all subscribers + for sub in client.subscribe_events.call_args_list: + callback = sub.kwargs["callback"] + event_filter = sub.kwargs.get("event_filter") + if event_filter in (None, event): + callback(event, data) await hass.async_block_till_done() diff --git a/tests/components/matter/fixtures/nodes/eve-energy-plug.json b/tests/components/matter/fixtures/nodes/eve-energy-plug.json new file mode 100644 index 00000000000..03ff4ce7dba --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eve-energy-plug.json @@ -0,0 +1,649 @@ +{ + "node_id": 83, + "date_commissioned": "2023-11-30T14:39:37.020026", + "last_interview": "2023-11-30T14:39:37.020029", + "interview_version": 5, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 5 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Eve Systems", + "0/40/2": 4874, + "0/40/3": "Eve Energy Plug", + "0/40/4": 80, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 1, + "0/40/8": "1.1", + "0/40/9": 6650, + "0/40/10": "3.2.1", + "0/40/15": "RV44L1A00081", + "0/40/18": "26E8F90561D17C42", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [ + { + "1": 2312386028615903905, + "2": 0, + "254": 1 + } + ], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "cfUKbvsdfsBjT+0=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "cfUKbvBjdsffwT+0=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "ymtKI/b4u+4=", + "5": [], + "6": [ + "/oAAAAA13414AAADIa0oj9vi77g==", + "/XH1Cm71434wAAB8TZpoASmxuw==", + "/RtUBAb134134mAAAPypryIKqshA==" + ], + "7": 4 + } + ], + "0/51/1": 95, + "0/51/2": 268574, + "0/51/3": 4406, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome23", + "0/53/3": 14707, + "0/53/4": 8211480967175688173, + "0/53/5": "QP1x9Qfwefu8AAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 13418684826835773064, + "1": 9, + "2": 3072, + "3": 56455, + "4": 84272, + "5": 1, + "6": -89, + "7": -88, + "8": 16, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3054316089463545304, + "1": 2, + "2": 12288, + "3": 17170, + "4": 58113, + "5": 3, + "6": -45, + "7": -46, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3650476115380598997, + "1": 13, + "2": 15360, + "3": 172475, + "4": 65759, + "5": 3, + "6": -17, + "7": -18, + "8": 12, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 11968039652259981925, + "1": 21, + "2": 21504, + "3": 127929, + "4": 55363, + "5": 3, + "6": -74, + "7": -72, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17156405262946673420, + "1": 22, + "2": 22528, + "3": 22063, + "4": 137698, + "5": 1, + "6": -92, + "7": -92, + "8": 34, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17782243871947087975, + "1": 18, + "2": 23552, + "3": 157044, + "4": 122272, + "5": 2, + "6": -81, + "7": -82, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8276316979900166010, + "1": 17, + "2": 31744, + "3": 486113, + "4": 298427, + "5": 2, + "6": -83, + "7": -82, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 9121696247933828996, + "1": 48, + "2": 53248, + "3": 651530, + "4": 161559, + "5": 3, + "6": -70, + "7": -71, + "8": 15, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 13418684826835773064, + "1": 3072, + "2": 3, + "3": 15, + "4": 1, + "5": 1, + "6": 1, + "7": 9, + "8": true, + "9": true + }, + { + "0": 0, + "1": 7168, + "2": 7, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 76, + "8": true, + "9": false + }, + { + "0": 0, + "1": 10240, + "2": 10, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 243, + "8": true, + "9": false + }, + { + "0": 3054316089463545304, + "1": 12288, + "2": 12, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 2, + "8": true, + "9": true + }, + { + "0": 3650476115380598997, + "1": 15360, + "2": 15, + "3": 12, + "4": 1, + "5": 3, + "6": 3, + "7": 14, + "8": true, + "9": true + }, + { + "0": 11968039652259981925, + "1": 21504, + "2": 21, + "3": 15, + "4": 1, + "5": 3, + "6": 2, + "7": 22, + "8": true, + "9": true + }, + { + "0": 17156405262946673420, + "1": 22528, + "2": 22, + "3": 52, + "4": 1, + "5": 1, + "6": 0, + "7": 23, + "8": true, + "9": true + }, + { + "0": 17782243871947087975, + "1": 23552, + "2": 23, + "3": 15, + "4": 1, + "5": 2, + "6": 2, + "7": 19, + "8": true, + "9": true + }, + { + "0": 0, + "1": 29696, + "2": 29, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 8276316979900166010, + "1": 31744, + "2": 31, + "3": 52, + "4": 1, + "5": 2, + "6": 2, + "7": 18, + "8": true, + "9": true + }, + { + "0": 0, + "1": 39936, + "2": 39, + "3": 52, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 9121696247933828996, + "1": 53248, + "2": 52, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 48, + "8": true, + "9": true + }, + { + "0": 14585833336497290222, + "1": 54272, + "2": 53, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + } + ], + "0/53/9": 1828774034, + "0/53/10": 68, + "0/53/11": 237, + "0/53/12": 170, + "0/53/13": 23, + "0/53/14": 2, + "0/53/15": 1, + "0/53/16": 2, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 2, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 293884, + "0/53/23": 278934, + "0/53/24": 14950, + "0/53/25": 278894, + "0/53/26": 278468, + "0/53/27": 14990, + "0/53/28": 293844, + "0/53/29": 0, + "0/53/30": 40, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 65244, + "0/53/34": 426, + "0/53/35": 0, + "0/53/36": 87, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 6687540, + "0/53/40": 142626, + "0/53/41": 106835, + "0/53/42": 246171, + "0/53/43": 0, + "0/53/44": 541, + "0/53/45": 40, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 6360718, + "0/53/49": 2141, + "0/53/50": 35259, + "0/53/51": 4374, + "0/53/52": 0, + "0/53/53": 568, + "0/53/54": 18599, + "0/53/55": 19143, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [0, 0, 0, 0], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRUxgkBwEkCAEwCUEEg58CF25hrI1R598dXwRapPCYUjahad5XkJMrA0tZb8HXO67XlyD4L+1ljtb6IAHhxjOGew2jNVSQDH1aqRGsODcKNQEoARgkAgE2AwQCBAEYMAQUkpBmmh0G57MnnxYDgxZuAZBezjYwBRTphWiJ/NqGe3Cx3Nj8H02NgGioSRgwC0CCOOCnKlhpegJmaH8vSIO38MQcJq+qV85UPPqaYc8dakaAnASvYeurP41Jw4KrCqyLMNRhUwqeyKoql6iQFKNAGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEYztrLK2UY1ORHUEFLO7PDfVjw/MnMDNX5kjdHHDU7npeITnSyg/kxxUM+pD7ccxfDuHQKHbBq9+qbJi8oGik8DcKNQEpARgkAmAwBBTphWiJ/NqGe3Cx3Nj8H02NgGioSTAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQOOcZAL8XEktvE5sjrUmFNhkP2g3Ef+4BHtogItdZYyA9E/WbzW25E0UxZInwjjIzH3YimDUZVoEWGML8NV2kCEY", + "254": 5 + } + ], + "0/62/1": [ + { + "1": "BIbR4Iu8CNIdxKRkSjTb1LKY3nzCbFVwDrjkRe4WDorCiMZHJmypZW24wBgAHxNo8D00QWw29llu8FH1eOtmHIo=", + "2": 4937, + "3": 1, + "4": 3878431683, + "5": "Thuis", + "254": 1 + }, + { + "1": "BLlk4ui4wSQ+xz89jB5nBRQUVYdY9H2dBUawGXVUxa2bsKh2k8CHijv1tkz1dThPXA9UK8jOAZ+7Mi+y7BPuAcg=", + "2": 4996, + "3": 2, + "4": 3763070728, + "5": "", + "254": 2 + }, + { + "1": "BAg5aeR7RuFKZhukCxMGglCd00dKlhxGq8BbjeyZClKz5kN2Ytzav0xWsiWEEb3s9uvMIYFoQYULnSJvOMTcD14=", + "2": 65521, + "3": 1, + "4": 83, + "5": "", + "254": 5 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AycUxofpv3kE1HwkFQEYJgS2Ty8rJgU2gxAtNwYnFMaH6b95BNR8JBUBGCQHASQIATAJQQSG0eCLvAjSHcSkZEo029SymN58wmxVcA645EXuFg6KwojGRyZsqWVtuMAYAB8TaPA9NEFsNvZZbvBR9XjrZhyKNwo1ASkBGCQCYDAEFNnFRJ+9qQIJtsM+LRdMdmCY3bQ4MAUU2cVEn72pAgm2wz4tF0x2YJjdtDgYMAtAFDv6Ouh7ugAGLiCjBQaEXCIAe0AkaaN8dBPskCZXOODjuZ1DCr4/f5IYg0rN2zFDUDTvG3GCxoI1+A7BvSjiNRg=", + "FTABAQAkAgE3AycUjuqR8vTQCmEkFQIYJgTFTy8rJgVFgxAtNwYnFI7qkfL00AphJBUCGCQHASQIATAJQQS5ZOLouMEkPsc/PYweZwUUFFWHWPR9nQVGsBl1VMWtm7CodpPAh4o79bZM9XU4T1wPVCvIzgGfuzIvsuwT7gHINwo1ASkBGCQCYDAEFKEEplpzAvCzsc5ga6CFmqmsv5onMAUUoQSmWnMC8LOxzmBroIWaqay/micYMAtAYkkA8OZFIGpxBEYYT+3A7Okba4WOq4NtwctIIZvCM48VU8pxQNjVvHMcJWPOP1Wh2Bw1VH7/Sg9lt9DL4DAwjBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEECDlp5HtG4UpmG6QLEwaCUJ3TR0qWHEarwFuN7JkKUrPmQ3Zi3Nq/TFayJYQRvez268whgWhBhQudIm84xNwPXjcKNQEpARgkAmAwBBTJ3+WZAQkWgZboUpiyZL3FV8R8UzAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQO9QSAdvJkM6b/wIc07MCw1ma46lTyGYG8nvpn0ICI73nuD3QeaWwGIQTkVGEpzF+TuDK7gtTz7YUrR+PSnvMk8Y" + ], + "0/62/5": 5, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 266, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 29, 319486977], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/319486977/319422464": "AAFQCwIAAAMC+xkEDFJWNDRMMUEwMDA4MZwBAP8EAQIA1PkBAWABZNAEAAAAAEUFBQAAAABGCQUAAAAOAABCBkkGBQwIEIABRBEFFAAFAzwAAAAAAAAAAAAAAEcRBSoh/CGWImgjeAAAADwAAABIBgUAAAAAAEoGBQAAAAAA", + "1/319486977/319422466": "BEZiAQAAAAAAAAAABgsCDAINAgcCDgEBAn4PABAAWgAAs8c+AQEA", + "1/319486977/319422467": "EgtaAAB74T4BDwAANwkAAAAA", + "1/319486977/319422471": 0, + "1/319486977/319422472": 238.8000030517578, + "1/319486977/319422473": 0.0, + "1/319486977/319422474": 0.0, + "1/319486977/319422475": 0.2200000286102295, + "1/319486977/319422476": 0, + "1/319486977/319422478": 0, + "1/319486977/319422481": false, + "1/319486977/319422482": 54272, + "1/319486977/65533": 1, + "1/319486977/65528": [], + "1/319486977/65529": [], + "1/319486977/65531": [ + 65528, 65529, 65531, 319422464, 319422465, 319422466, 319422467, + 319422468, 319422469, 319422471, 319422472, 319422473, 319422474, + 319422475, 319422476, 319422478, 319422481, 319422482, 65533 + ] + }, + "attribute_subscriptions": [], + "last_subscription_attempt": 0 +} diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 8ed309f61df..35e6673114e 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -145,9 +145,12 @@ async def test_node_added_subscription( ) -> None: """Test subscription to new devices work.""" assert matter_client.subscribe_events.call_count == 4 - assert matter_client.subscribe_events.call_args[0][1] == EventType.NODE_ADDED + assert ( + matter_client.subscribe_events.call_args.kwargs["event_filter"] + == EventType.NODE_ADDED + ) - node_added_callback = matter_client.subscribe_events.call_args[0][0] + node_added_callback = matter_client.subscribe_events.call_args.kwargs["callback"] node_data = load_and_parse_node_fixture("onoff-light") node = MatterNode( dataclass_from_dict( diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 0d8f892f992..5b343b8c4e5 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -1,5 +1,6 @@ """Test Matter sensors.""" -from unittest.mock import MagicMock +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode import pytest @@ -14,6 +15,8 @@ from .common import ( trigger_subscription_callback, ) +from tests.common import async_fire_time_changed + @pytest.fixture(name="flow_sensor_node") async def flow_sensor_node_fixture( @@ -63,6 +66,16 @@ async def temperature_sensor_node_fixture( ) +@pytest.fixture(name="eve_energy_plug_node") +async def eve_energy_plug_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Eve Energy Plug node.""" + return await setup_integration_with_node_fixture( + hass, "eve-energy-plug", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( @@ -208,3 +221,70 @@ async def test_battery_sensor( assert entry assert entry.entity_category == EntityCategory.DIAGNOSTIC + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_eve_energy_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + eve_energy_plug_node: MatterNode, +) -> None: + """Test Energy sensors created from Eve Energy custom cluster.""" + # power sensor + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "W" + assert state.attributes["device_class"] == "power" + assert state.attributes["friendly_name"] == "Eve Energy Plug Power" + + # voltage sensor + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "238.800003051758" + assert state.attributes["unit_of_measurement"] == "V" + assert state.attributes["device_class"] == "voltage" + assert state.attributes["friendly_name"] == "Eve Energy Plug Voltage" + + # energy sensor + entity_id = "sensor.eve_energy_plug_energy" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.220000028610229" + assert state.attributes["unit_of_measurement"] == "kWh" + assert state.attributes["device_class"] == "energy" + assert state.attributes["friendly_name"] == "Eve Energy Plug Energy" + assert state.attributes["state_class"] == "total_increasing" + + # current sensor + entity_id = "sensor.eve_energy_plug_current" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "A" + assert state.attributes["device_class"] == "current" + assert state.attributes["friendly_name"] == "Eve Energy Plug Current" + + # test if the sensor gets polled on interval + eve_energy_plug_node.update_attribute("1/319486977/319422472", 237.0) + async_fire_time_changed(hass, datetime.now(UTC) + timedelta(seconds=31)) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "237.0" + + # test extra poll triggered when secondary value (switch state) changes + set_node_attribute(eve_energy_plug_node, 1, 6, 0, True) + eve_energy_plug_node.update_attribute("1/319486977/319422474", 5.0) + with patch("homeassistant.components.matter.entity.EXTRA_POLL_DELAY", 0.0): + await trigger_subscription_callback(hass, matter_client) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "5.0" From 48cce1a854cfd28bf37a3b79011cd9604ef81571 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Mon, 4 Dec 2023 11:37:09 -0800 Subject: [PATCH 488/643] Exclude Todoist sub-tasks for the todo platform (#104914) --- homeassistant/components/todoist/todo.py | 3 +++ tests/components/todoist/conftest.py | 3 ++- tests/components/todoist/test_todo.py | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 64e83b8cc6e..6231a6878ae 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -85,6 +85,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit for task in self.coordinator.data: if task.project_id != self._project_id: continue + if task.parent_id is not None: + # Filter out sub-tasks until they are supported by the UI. + continue if task.is_completed: status = TodoItemStatus.COMPLETED else: diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 4e4d41b6914..42251b0ea18 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -46,6 +46,7 @@ def make_api_task( due: Due | None = None, project_id: str | None = None, description: str | None = None, + parent_id: str | None = None, ) -> Task: """Mock a todoist Task instance.""" return Task( @@ -61,7 +62,7 @@ def make_api_task( id=id or "1", labels=["Label1"], order=1, - parent_id=None, + parent_id=parent_id, priority=1, project_id=project_id or PROJECT_ID, section_id=None, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index aa00e2c2ff4..1e94b52149c 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -51,6 +51,14 @@ def set_time_zone(hass: HomeAssistant) -> None: ], "0", ), + ( + [ + make_api_task( + id="12345", content="sub-task", is_completed=False, parent_id="1" + ) + ], + "0", + ), ], ) async def test_todo_item_state( From 56e325a2b107369304bc830110574c9361745531 Mon Sep 17 00:00:00 2001 From: Marco <24938492+Marco98@users.noreply.github.com> Date: Tue, 5 Dec 2023 09:21:03 +0100 Subject: [PATCH 489/643] Fix Mikrotik rename from wifiwave2 to wifi for upcoming RouterOS 7.13 (#104966) Co-authored-by: Marco98 --- homeassistant/components/mikrotik/const.py | 4 ++++ homeassistant/components/mikrotik/hub.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 4354b9b06bd..8407dd14a6e 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -25,9 +25,11 @@ CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" WIRELESS: Final = "wireless" WIFIWAVE2: Final = "wifiwave2" +WIFI: Final = "wifi" IS_WIRELESS: Final = "is_wireless" IS_CAPSMAN: Final = "is_capsman" IS_WIFIWAVE2: Final = "is_wifiwave2" +IS_WIFI: Final = "is_wifi" MIKROTIK_SERVICES: Final = { @@ -38,9 +40,11 @@ MIKROTIK_SERVICES: Final = { INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", WIFIWAVE2: "/interface/wifiwave2/registration-table/print", + WIFI: "/interface/wifi/registration-table/print", IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", IS_WIFIWAVE2: "/interface/wifiwave2/print", + IS_WIFI: "/interface/wifi/print", } diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 9e0a610c770..af7dfb2ab2c 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -31,10 +31,12 @@ from .const import ( IDENTITY, INFO, IS_CAPSMAN, + IS_WIFI, IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + WIFI, WIFIWAVE2, WIRELESS, ) @@ -60,6 +62,7 @@ class MikrotikData: self.support_capsman: bool = False self.support_wireless: bool = False self.support_wifiwave2: bool = False + self.support_wifi: bool = False self.hostname: str = "" self.model: str = "" self.firmware: str = "" @@ -101,6 +104,7 @@ class MikrotikData: self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) + self.support_wifi = bool(self.command(MIKROTIK_SERVICES[IS_WIFI])) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -128,6 +132,9 @@ class MikrotikData: elif self.support_wifiwave2: _LOGGER.debug("Hub supports wifiwave2 Interface") device_list = wireless_devices = self.get_list_from_interface(WIFIWAVE2) + elif self.support_wifi: + _LOGGER.debug("Hub supports wifi Interface") + device_list = wireless_devices = self.get_list_from_interface(WIFI) if not device_list or self.force_dhcp: device_list = self.all_devices From fd4a05fc7a6e519b5e29469d17a2283b7183f52c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Dec 2023 10:47:49 +0100 Subject: [PATCH 490/643] Minor improvements of deprecation helper (#104980) --- homeassistant/helpers/deprecation.py | 44 +++++++++++++++++++--------- tests/util/yaml/test_init.py | 4 +-- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index c499dd0b6cd..5a0682fdda2 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -99,7 +99,11 @@ def get_deprecated( def deprecated_class( replacement: str, ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark class as deprecated and provide a replacement class to be used instead.""" + """Mark class as deprecated and provide a replacement class to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(cls: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate class as deprecated.""" @@ -107,7 +111,7 @@ def deprecated_class( @functools.wraps(cls) def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original class.""" - _print_deprecation_warning(cls, replacement, "class") + _print_deprecation_warning(cls, replacement, "class", "instantiated") return cls(*args, **kwargs) return deprecated_cls @@ -118,7 +122,11 @@ def deprecated_class( def deprecated_function( replacement: str, ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark function as deprecated and provide a replacement to be used instead.""" + """Mark function as deprecated and provide a replacement to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(func: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate function as deprecated.""" @@ -126,7 +134,7 @@ def deprecated_function( @functools.wraps(func) def deprecated_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original function.""" - _print_deprecation_warning(func, replacement, "function") + _print_deprecation_warning(func, replacement, "function", "called") return func(*args, **kwargs) return deprecated_func @@ -134,10 +142,23 @@ def deprecated_function( return deprecated_decorator -def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None: +def _print_deprecation_warning( + obj: Any, + replacement: str, + description: str, + verb: str, +) -> None: logger = logging.getLogger(obj.__module__) try: integration_frame = get_integration_frame() + except MissingIntegrationFrame: + logger.warning( + "%s is a deprecated %s. Use %s instead", + obj.__name__, + description, + replacement, + ) + else: if integration_frame.custom_integration: hass: HomeAssistant | None = None with suppress(HomeAssistantError): @@ -149,10 +170,11 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> ) logger.warning( ( - "%s was called from %s, this is a deprecated %s. Use %s instead," + "%s was %s from %s, this is a deprecated %s. Use %s instead," " please %s" ), obj.__name__, + verb, integration_frame.integration, description, replacement, @@ -160,16 +182,10 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> ) else: logger.warning( - "%s was called from %s, this is a deprecated %s. Use %s instead", + "%s was %s from %s, this is a deprecated %s. Use %s instead", obj.__name__, + verb, integration_frame.integration, description, replacement, ) - except MissingIntegrationFrame: - logger.warning( - "%s is a deprecated %s. Use %s instead", - obj.__name__, - description, - replacement, - ) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 3a2d9b3734d..6f6f48813ce 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -636,8 +636,8 @@ async def test_deprecated_loaders( ): loader_class() assert ( - f"{loader_class.__name__} was called from hue, this is a deprecated class. " - f"Use {new_class} instead" + f"{loader_class.__name__} was instantiated from hue, this is a deprecated " + f"class. Use {new_class} instead" ) in caplog.text From c62c002657d51ca13c789f8c4b627c08e9698f39 Mon Sep 17 00:00:00 2001 From: Bartosz Dokurno Date: Mon, 4 Dec 2023 14:58:37 +0100 Subject: [PATCH 491/643] Update Todoist config flow URL (#104992) --- homeassistant/components/todoist/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index b8c79210dfb..94b4ad31826 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -16,7 +16,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -SETTINGS_URL = "https://todoist.com/app/settings/integrations" +SETTINGS_URL = "https://app.todoist.com/app/settings/integrations/developer" STEP_USER_DATA_SCHEMA = vol.Schema( { From 65c8aa32494430b9531153b0b6112b8ed858527a Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 08:55:15 +0100 Subject: [PATCH 492/643] Make unifi RX-/TX-sensors diagnostic entities (#105022) --- homeassistant/components/unifi/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 3d0ffa1896e..1e4a4520d5c 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -151,6 +151,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor RX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:upload", @@ -171,6 +172,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor TX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:download", From 2f727d5fe15380fabda39bb8e81e11c92cc7d324 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 09:42:43 +0100 Subject: [PATCH 493/643] Fix stuck clients in UniFi options (#105028) --- homeassistant/components/unifi/config_flow.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index a678517eca9..e1867b2df2e 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -8,6 +8,7 @@ Configuration of options through options flow. from __future__ import annotations from collections.abc import Mapping +import operator import socket from types import MappingProxyType from typing import Any @@ -309,6 +310,11 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): client.mac: f"{client.name or client.hostname} ({client.mac})" for client in self.controller.api.clients.values() } + clients |= { + mac: f"Unknown ({mac})" + for mac in self.options.get(CONF_CLIENT_SOURCE, []) + if mac not in clients + } return self.async_show_form( step_id="configure_entity_sources", @@ -317,7 +323,9 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_CLIENT_SOURCE, default=self.options.get(CONF_CLIENT_SOURCE, []), - ): cv.multi_select(clients), + ): cv.multi_select( + dict(sorted(clients.items(), key=operator.itemgetter(1))) + ), } ), last_step=False, From 7cb383146a9566fd0ae1213f0d177f14fa2ce5d7 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 08:50:32 +0100 Subject: [PATCH 494/643] Make UniFi WiFi clients numerical (#105032) --- homeassistant/components/unifi/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 1e4a4520d5c..4d5cf49b5c9 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -233,6 +233,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( key="WLAN clients", entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, allowed_fn=lambda controller, obj_id: True, api_handler_fn=lambda api: api.wlans, available_fn=async_wlan_available_fn, From 55c686ad0303bfaa6ba30fc53fcb86436d5be161 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 11:36:26 +0100 Subject: [PATCH 495/643] Don't use deprecated_class decorator on deprecated YAML classes (#105063) --- homeassistant/util/yaml/loader.py | 60 ++++++++++++++++++++++++++++--- tests/util/yaml/test_init.py | 15 ++++---- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 275a51cd760..4a14afb53b2 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -1,7 +1,7 @@ """Custom loader.""" from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import suppress import fnmatch from io import StringIO, TextIOWrapper @@ -23,7 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.deprecation import deprecated_class +from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -137,10 +137,36 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("FastSafeLoader") class SafeLoader(FastSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.__init__(*args, **kwargs) + + @classmethod + def add_constructor(cls, tag: str, constructor: Callable) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.add_constructor(tag, constructor) + + @classmethod + def add_multi_constructor( + cls, tag_prefix: str, multi_constructor: Callable + ) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) + + @staticmethod + def __report_deprecated() -> None: + """Log deprecation warning.""" + report( + "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -151,10 +177,36 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets -@deprecated_class("PythonSafeLoader") class SafeLineLoader(PythonSafeLoader): """Provided for backwards compatibility. Logs when instantiated.""" + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.__init__(*args, **kwargs) + + @classmethod + def add_constructor(cls, tag: str, constructor: Callable) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.add_constructor(tag, constructor) + + @classmethod + def add_multi_constructor( + cls, tag_prefix: str, multi_constructor: Callable + ) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) + + @staticmethod + def __report_deprecated() -> None: + """Log deprecation warning.""" + report( + "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 6f6f48813ce..c4e5c58e235 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -590,7 +590,7 @@ async def test_loading_actual_file_with_syntax_error( def mock_integration_frame() -> Generator[Mock, None, None]: """Mock as if we're calling code from inside an integration.""" correct_frame = Mock( - filename="/home/paulus/.homeassistant/custom_components/hue/light.py", + filename="/home/paulus/homeassistant/components/hue/light.py", lineno="23", line="self.light.is_on", ) @@ -614,12 +614,12 @@ def mock_integration_frame() -> Generator[Mock, None, None]: @pytest.mark.parametrize( - ("loader_class", "new_class"), + ("loader_class", "message"), [ - (yaml.loader.SafeLoader, "FastSafeLoader"), + (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), ( yaml.loader.SafeLineLoader, - "PythonSafeLoader", + "'SafeLineLoader' instead of 'PythonSafeLoader'", ), ], ) @@ -628,17 +628,14 @@ async def test_deprecated_loaders( mock_integration_frame: Mock, caplog: pytest.LogCaptureFixture, loader_class, - new_class: str, + message: str, ) -> None: """Test instantiating the deprecated yaml loaders logs a warning.""" with pytest.raises(TypeError), patch( "homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set() ): loader_class() - assert ( - f"{loader_class.__name__} was instantiated from hue, this is a deprecated " - f"class. Use {new_class} instead" - ) in caplog.text + assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text def test_string_annotated(try_both_loaders) -> None: From a076b7d992f166fd426ccdfaa9a282b3fceb417f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Dec 2023 15:57:40 +0100 Subject: [PATCH 496/643] Bump version to 2023.12.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b8ce579ffe8..ecb09a5d621 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 2d5333697bb..26114091715 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b2" +version = "2023.12.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 651df6b6987368816f1fd03b9d7e41d7d9fbf540 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 5 Dec 2023 10:51:51 -0500 Subject: [PATCH 497/643] Add calendar entity to Radarr (#79077) * Add calendar entity to Radarr * address feedback/add tests * black * uno mas * rework to coordinator * uno mas * move release atttribute writing * fix calendar items and attributes --- homeassistant/components/radarr/__init__.py | 4 +- homeassistant/components/radarr/calendar.py | 63 ++++++++++ .../components/radarr/coordinator.py | 105 ++++++++++++++++- tests/components/radarr/__init__.py | 18 +++ .../components/radarr/fixtures/calendar.json | 111 ++++++++++++++++++ tests/components/radarr/test_binary_sensor.py | 3 + tests/components/radarr/test_calendar.py | 41 +++++++ tests/components/radarr/test_config_flow.py | 2 + tests/components/radarr/test_init.py | 4 + tests/components/radarr/test_sensor.py | 2 + 10 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/radarr/calendar.py create mode 100644 tests/components/radarr/fixtures/calendar.json create mode 100644 tests/components/radarr/test_calendar.py diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 39258e2f787..b6b05b5b568 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -22,6 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( + CalendarUpdateCoordinator, DiskSpaceDataUpdateCoordinator, HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, @@ -31,7 +32,7 @@ from .coordinator import ( T, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -46,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = { + "calendar": CalendarUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), "movie": MoviesDataUpdateCoordinator(hass, host_configuration, radarr), diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py new file mode 100644 index 00000000000..3a5308fffd5 --- /dev/null +++ b/homeassistant/components/radarr/calendar.py @@ -0,0 +1,63 @@ +"""Support for Radarr calendar items.""" +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RadarrEntity +from .const import DOMAIN +from .coordinator import CalendarUpdateCoordinator, RadarrEvent + +CALENDAR_TYPE = EntityDescription( + key="calendar", + name=None, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Radarr calendar entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + async_add_entities([RadarrCalendarEntity(coordinator, CALENDAR_TYPE)]) + + +class RadarrCalendarEntity(RadarrEntity, CalendarEntity): + """A Radarr calendar entity.""" + + coordinator: CalendarUpdateCoordinator + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + if not self.coordinator.event: + return None + return CalendarEvent( + summary=self.coordinator.event.summary, + start=self.coordinator.event.start, + end=self.coordinator.event.end, + description=self.coordinator.event.description, + ) + + # pylint: disable-next=hass-return-type + async def async_get_events( # type: ignore[override] + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get all events in a specific time frame.""" + return await self.coordinator.async_get_events(start_date, end_date) + + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + if self.coordinator.event: + self._attr_extra_state_attributes = { + "release_type": self.coordinator.event.release_type + } + else: + self._attr_extra_state_attributes = {} + super().async_write_ha_state() diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index bd41810bfb8..c14603fe9ca 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -2,13 +2,23 @@ from __future__ import annotations from abc import ABC, abstractmethod -from datetime import timedelta +import asyncio +from dataclasses import dataclass +from datetime import date, datetime, timedelta from typing import Generic, TypeVar, cast -from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions +from aiopyarr import ( + Health, + RadarrCalendarItem, + RadarrMovie, + RootFolder, + SystemStatus, + exceptions, +) from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient +from homeassistant.components.calendar import CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -16,13 +26,26 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER -T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int) +T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int | None) + + +@dataclass +class RadarrEventMixIn: + """Mixin for Radarr calendar event.""" + + release_type: str + + +@dataclass +class RadarrEvent(CalendarEvent, RadarrEventMixIn): + """A class to describe a Radarr calendar event.""" class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): """Data update coordinator for the Radarr integration.""" config_entry: ConfigEntry + update_interval = timedelta(seconds=30) def __init__( self, @@ -35,7 +58,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=self.update_interval, ) self.api_client = api_client self.host_configuration = host_configuration @@ -101,3 +124,77 @@ class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator): return ( await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS) ).totalRecords + + +class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]): + """Calendar update coordinator.""" + + update_interval = timedelta(hours=1) + + def __init__( + self, + hass: HomeAssistant, + host_configuration: PyArrHostConfiguration, + api_client: RadarrClient, + ) -> None: + """Initialize.""" + super().__init__(hass, host_configuration, api_client) + self.event: RadarrEvent | None = None + self._events: list[RadarrEvent] = [] + + async def _fetch_data(self) -> None: + """Fetch the calendar.""" + self.event = None + _date = datetime.today() + while self.event is None: + await self.async_get_events(_date, _date + timedelta(days=1)) + for event in self._events: + if event.start >= _date.date(): + self.event = event + break + # Prevent infinite loop in case there is nothing recent in the calendar + if (_date - datetime.today()).days > 45: + break + _date = _date + timedelta(days=1) + + async def async_get_events( + self, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get cached events and request missing dates.""" + # remove older events to prevent memory leak + self._events = [ + e + for e in self._events + if e.start >= datetime.now().date() - timedelta(days=30) + ] + _days = (end_date - start_date).days + await asyncio.gather( + *( + self._async_get_events(d) + for d in ((start_date + timedelta(days=x)).date() for x in range(_days)) + if d not in (event.start for event in self._events) + ) + ) + return self._events + + async def _async_get_events(self, _date: date) -> None: + """Return events from specified date.""" + self._events.extend( + _get_calendar_event(evt) + for evt in await self.api_client.async_get_calendar( + start_date=_date, end_date=_date + timedelta(days=1) + ) + if evt.title not in (e.summary for e in self._events) + ) + + +def _get_calendar_event(event: RadarrCalendarItem) -> RadarrEvent: + """Return a RadarrEvent from an API event.""" + _date, _type = event.releaseDateType() + return RadarrEvent( + summary=event.title, + start=_date - timedelta(days=1), + end=_date, + description=event.overview.replace(":", ";"), + release_type=_type, + ) diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index f7bdf232c9e..47204ebf537 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -102,6 +102,18 @@ def mock_connection( ) +def mock_calendar( + aioclient_mock: AiohttpClientMocker, + url: str = URL, +) -> None: + """Mock radarr connection.""" + aioclient_mock.get( + f"{url}/api/v3/calendar", + text=load_fixture("radarr/calendar.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + def mock_connection_error( aioclient_mock: AiohttpClientMocker, url: str = URL, @@ -120,6 +132,7 @@ def mock_connection_invalid_auth( aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/rootfolder", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/calendar", status=HTTPStatus.UNAUTHORIZED) def mock_connection_server_error( @@ -136,6 +149,9 @@ def mock_connection_server_error( aioclient_mock.get( f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR ) + aioclient_mock.get( + f"{url}/api/v3/calendar", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) async def setup_integration( @@ -172,6 +188,8 @@ async def setup_integration( single_return=single_return, ) + mock_calendar(aioclient_mock, url) + if not skip_entry_setup: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/radarr/fixtures/calendar.json b/tests/components/radarr/fixtures/calendar.json new file mode 100644 index 00000000000..2bf0338d639 --- /dev/null +++ b/tests/components/radarr/fixtures/calendar.json @@ -0,0 +1,111 @@ +[ + { + "title": "test", + "originalTitle": "string", + "alternateTitles": [], + "secondaryYearSourceId": 0, + "sortTitle": "string", + "sizeOnDisk": 0, + "status": "string", + "overview": "test2", + "physicalRelease": "2021-12-03T00:00:00Z", + "digitalRelease": "2020-08-11T00:00:00Z", + "images": [ + { + "coverType": "poster", + "url": "string" + } + ], + "website": "string", + "year": 0, + "hasFile": true, + "youTubeTrailerId": "string", + "studio": "string", + "path": "string", + "qualityProfileId": 0, + "monitored": true, + "minimumAvailability": "string", + "isAvailable": true, + "folderName": "string", + "runtime": 0, + "cleanTitle": "string", + "imdbId": "string", + "tmdbId": 0, + "titleSlug": "0", + "genres": ["string"], + "tags": [], + "added": "2020-07-16T13:25:37Z", + "ratings": { + "imdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "tmdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "metacritic": { + "votes": 0, + "value": 0, + "type": "string" + }, + "rottenTomatoes": { + "votes": 0, + "value": 0, + "type": "string" + } + }, + "movieFile": { + "movieId": 0, + "relativePath": "string", + "path": "string", + "size": 0, + "dateAdded": "2021-06-01T04:08:20Z", + "sceneName": "string", + "indexerFlags": 0, + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": false + } + }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 0.0, + "audioCodec": "string", + "audioLanguages": "string", + "audioStreamCount": 0, + "videoBitDepth": 0, + "videoBitrate": 0, + "videoCodec": "string", + "videoFps": 0.0, + "resolution": "string", + "runTime": "00:00:00", + "scanType": "string", + "subtitles": "string" + }, + "originalFilePath": "string", + "qualityCutoffNotMet": false, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "releaseGroup": "string", + "edition": "string", + "id": 0 + }, + "id": 0 + } +] diff --git a/tests/components/radarr/test_binary_sensor.py b/tests/components/radarr/test_binary_sensor.py index b6303de4a48..cd1df721d5f 100644 --- a/tests/components/radarr/test_binary_sensor.py +++ b/tests/components/radarr/test_binary_sensor.py @@ -1,4 +1,6 @@ """The tests for Radarr binary sensor platform.""" +import pytest + from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_binary_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_calendar.py b/tests/components/radarr/test_calendar.py new file mode 100644 index 00000000000..61e9bc27c9b --- /dev/null +++ b/tests/components/radarr/test_calendar.py @@ -0,0 +1,41 @@ +"""The tests for Radarr calendar platform.""" +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.radarr.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_calendar( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for successfully setting up the Radarr platform.""" + freezer.move_to("2021-12-02 00:00:00-08:00") + entry = await setup_integration(hass, aioclient_mock) + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_ON + assert state.attributes.get("all_day") is True + assert state.attributes.get("description") == "test2" + assert state.attributes.get("end_time") == "2021-12-03 00:00:00" + assert state.attributes.get("message") == "test" + assert state.attributes.get("release_type") == "physicalRelease" + assert state.attributes.get("start_time") == "2021-12-02 00:00:00" + + freezer.tick(timedelta(hours=16)) + await coordinator.async_refresh() + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_OFF + assert len(state.attributes) == 1 + assert state.attributes.get("release_type") is None diff --git a/tests/components/radarr/test_config_flow.py b/tests/components/radarr/test_config_flow.py index 5527e311114..5eab7c02bb9 100644 --- a/tests/components/radarr/test_config_flow.py +++ b/tests/components/radarr/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from aiopyarr import exceptions +import pytest from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER @@ -135,6 +136,7 @@ async def test_zero_conf(hass: HomeAssistant) -> None: assert result["data"] == CONF_DATA +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_full_reauth_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index f16e5895633..62660c12874 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -1,4 +1,6 @@ """Test Radarr integration.""" +import pytest + from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -9,6 +11,7 @@ from . import create_entry, mock_connection_invalid_auth, setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test unload.""" entry = await setup_integration(hass, aioclient_mock) @@ -43,6 +46,7 @@ async def test_async_setup_entry_auth_failed( assert not hass.data.get(DOMAIN) +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_device_info( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 90ab683037b..11f55b712cd 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -14,6 +14,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") @pytest.mark.parametrize( ("windows", "single", "root_folder"), [ @@ -65,6 +66,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_windows( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: From a8ca73a7dd033de0cc290e21675af168e4365c7c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Dec 2023 17:13:29 +0100 Subject: [PATCH 498/643] Finish scaffold config flow with either abort or create entry (#105012) --- .../config_flow/tests/test_config_flow.py | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index f08f95e74fc..bb9e6380cdc 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -41,7 +41,9 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -63,8 +65,36 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() -async def test_form_cannot_connect(hass: HomeAssistant) -> None: + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -85,3 +115,30 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 From 5b55c7da5fbaeffd3aa61f32dcb40a21ba192b6c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 18:08:11 +0100 Subject: [PATCH 499/643] Remove logic converting empty or falsy YAML to empty dict (#103912) * Correct logic converting empty YAML to empty dict * Modify according to github comments * Add load_yaml_dict helper * Update check_config script * Update tests --- homeassistant/components/blueprint/models.py | 3 +- .../components/lovelace/dashboard.py | 6 ++- homeassistant/components/notify/legacy.py | 6 +-- .../components/python_script/__init__.py | 4 +- homeassistant/components/tts/legacy.py | 8 ++-- homeassistant/config.py | 10 ++--- homeassistant/helpers/service.py | 6 ++- homeassistant/scripts/check_config.py | 2 +- homeassistant/util/yaml/__init__.py | 11 ++++- homeassistant/util/yaml/loader.py | 40 ++++++++++++++----- script/hassfest/services.py | 6 +-- tests/components/automation/test_init.py | 2 +- tests/components/lovelace/test_cast.py | 2 +- tests/components/lovelace/test_dashboard.py | 8 ++-- tests/components/lovelace/test_resources.py | 2 +- .../components/lovelace/test_system_health.py | 2 +- tests/components/samsungtv/test_trigger.py | 2 +- tests/components/webostv/test_trigger.py | 2 +- tests/components/zwave_js/test_trigger.py | 4 +- tests/util/yaml/test_init.py | 33 +++++++++++++++ 20 files changed, 112 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index ddf57aa6eee..63a1c1b45f0 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -215,7 +215,7 @@ class DomainBlueprints: def _load_blueprint(self, blueprint_path) -> Blueprint: """Load a blueprint.""" try: - blueprint_data = yaml.load_yaml(self.blueprint_folder / blueprint_path) + blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path) except FileNotFoundError as err: raise FailedToLoad( self.domain, @@ -225,7 +225,6 @@ class DomainBlueprints: except HomeAssistantError as err: raise FailedToLoad(self.domain, blueprint_path, err) from err - assert isinstance(blueprint_data, dict) return Blueprint( blueprint_data, expected_domain=self.domain, path=blueprint_path ) diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index e1641451221..d935ad9bff5 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_FILENAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, storage -from homeassistant.util.yaml import Secrets, load_yaml +from homeassistant.util.yaml import Secrets, load_yaml_dict from .const import ( CONF_ICON, @@ -201,7 +201,9 @@ class LovelaceYAML(LovelaceConfig): is_updated = self._cache is not None try: - config = load_yaml(self.path, Secrets(Path(self.hass.config.config_dir))) + config = load_yaml_dict( + self.path, Secrets(Path(self.hass.config.config_dir)) + ) except FileNotFoundError: raise ConfigNotFound from None diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 93b6833edc6..7c78bfc44d3 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -17,7 +17,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform, async_start_setup from homeassistant.util import slugify -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_DATA, @@ -280,8 +280,8 @@ class BaseNotificationService: # Load service descriptions from notify/services.yaml integration = await async_get_integration(hass, DOMAIN) services_yaml = integration.file_path / "services.yaml" - self.services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + self.services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_register_services(self) -> None: diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 10751d28c06..098603b9494 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -27,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import raise_if_invalid_filename import homeassistant.util.dt as dt_util -from homeassistant.util.yaml.loader import load_yaml +from homeassistant.util.yaml.loader import load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -120,7 +120,7 @@ def discover_scripts(hass): # Load user-provided service descriptions from python_scripts/services.yaml services_yaml = os.path.join(path, "services.yaml") if os.path.exists(services_yaml): - services_dict = load_yaml(services_yaml) + services_dict = load_yaml_dict(services_yaml) else: services_dict = {} diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index a52bcb802ab..05be2e284e3 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -6,7 +6,7 @@ from collections.abc import Coroutine, Mapping from functools import partial import logging from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -31,7 +31,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_prepare_setup_platform -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_CACHE, @@ -104,8 +104,8 @@ async def async_setup_legacy( # Load service descriptions from tts/services.yaml services_yaml = Path(__file__).parent / "services.yaml" - services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_setup_platform( diff --git a/homeassistant/config.py b/homeassistant/config.py index 6dd8bc21471..95dd42737a0 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -66,7 +66,7 @@ from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system -from .util.yaml import SECRET_YAML, Secrets, load_yaml +from .util.yaml import SECRET_YAML, Secrets, YamlTypeError, load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -476,15 +476,15 @@ def load_yaml_config_file( This method needs to run in an executor. """ - conf_dict = load_yaml(config_path, secrets) - - if not isinstance(conf_dict, dict): + try: + conf_dict = load_yaml_dict(config_path, secrets) + except YamlTypeError as exc: msg = ( f"The configuration file {os.path.basename(config_path)} " "does not contain a dictionary" ) _LOGGER.error(msg) - raise HomeAssistantError(msg) + raise HomeAssistantError(msg) from exc # Convert values to dictionaries if they are None for key, value in conf_dict.items(): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 32f51a924f7..2ada25bd4cd 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -42,7 +42,7 @@ from homeassistant.exceptions import ( UnknownUser, ) from homeassistant.loader import Integration, async_get_integrations, bind_hass -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml.loader import JSON_TYPE from . import ( @@ -542,7 +542,9 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T try: return cast( JSON_TYPE, - _SERVICES_SCHEMA(load_yaml(str(integration.file_path / "services.yaml"))), + _SERVICES_SCHEMA( + load_yaml_dict(str(integration.file_path / "services.yaml")) + ), ) except FileNotFoundError: _LOGGER.warning( diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 0e00d0b75f2..dcccdbccf40 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -32,7 +32,7 @@ REQUIREMENTS = ("colorlog==6.7.0",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml), - "load*": ("homeassistant.config.load_yaml", yaml_loader.load_yaml), + "load*": ("homeassistant.config.load_yaml_dict", yaml_loader.load_yaml_dict), "secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml), } diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index b3f1b7ecd43..fe4f01677cd 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -2,7 +2,14 @@ from .const import SECRET_YAML from .dumper import dump, save_yaml from .input import UndefinedSubstitution, extract_inputs, substitute -from .loader import Secrets, load_yaml, parse_yaml, secret_yaml +from .loader import ( + Secrets, + YamlTypeError, + load_yaml, + load_yaml_dict, + parse_yaml, + secret_yaml, +) from .objects import Input __all__ = [ @@ -11,7 +18,9 @@ __all__ = [ "dump", "save_yaml", "Secrets", + "YamlTypeError", "load_yaml", + "load_yaml_dict", "secret_yaml", "parse_yaml", "UndefinedSubstitution", diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 4a14afb53b2..60e917a6a99 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -36,6 +36,10 @@ _DictT = TypeVar("_DictT", bound=dict) _LOGGER = logging.getLogger(__name__) +class YamlTypeError(HomeAssistantError): + """Raised by load_yaml_dict if top level data is not a dict.""" + + class Secrets: """Store secrets while loading YAML.""" @@ -211,7 +215,7 @@ class SafeLineLoader(PythonSafeLoader): LoaderType = FastSafeLoader | PythonSafeLoader -def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: +def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE | None: """Load a YAML file.""" try: with open(fname, encoding="utf-8") as conf_file: @@ -221,6 +225,20 @@ def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: raise HomeAssistantError(exc) from exc +def load_yaml_dict(fname: str, secrets: Secrets | None = None) -> dict: + """Load a YAML file and ensure the top level is a dict. + + Raise if the top level is not a dict. + Return an empty dict if the file is empty. + """ + loaded_yaml = load_yaml(fname, secrets) + if loaded_yaml is None: + loaded_yaml = {} + if not isinstance(loaded_yaml, dict): + raise YamlTypeError(f"YAML file {fname} does not contain a dict") + return loaded_yaml + + def parse_yaml( content: str | TextIO | StringIO, secrets: Secrets | None = None ) -> JSON_TYPE: @@ -255,12 +273,7 @@ def _parse_yaml( secrets: Secrets | None = None, ) -> JSON_TYPE: """Load a YAML file.""" - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return ( - yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] - or NodeDictClass() - ) + return yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] @overload @@ -309,7 +322,10 @@ def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """ fname = os.path.join(os.path.dirname(loader.get_name()), node.value) try: - return _add_reference(load_yaml(fname, loader.secrets), loader, node) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + loaded_yaml = NodeDictClass() + return _add_reference(loaded_yaml, loader, node) except FileNotFoundError as exc: raise HomeAssistantError( f"{node.start_mark}: Unable to read file {fname}." @@ -339,7 +355,10 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDi filename = os.path.splitext(os.path.basename(fname))[0] if os.path.basename(fname) == SECRET_YAML: continue - mapping[filename] = load_yaml(fname, loader.secrets) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + continue + mapping[filename] = loaded_yaml return _add_reference(mapping, loader, node) @@ -364,9 +383,10 @@ def _include_dir_list_yaml( """Load multiple files from directory as a list.""" loc = os.path.join(os.path.dirname(loader.get_name()), node.value) return [ - load_yaml(f, loader.secrets) + loaded_yaml for f in _find_files(loc, "*.yaml") if os.path.basename(f) != SECRET_YAML + and (loaded_yaml := load_yaml(f, loader.secrets)) is not None ] diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 4a826f7cad9..580294705cf 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -13,7 +13,7 @@ from voluptuous.humanize import humanize_error from homeassistant.const import CONF_SELECTOR from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, selector, service -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .model import Config, Integration @@ -107,7 +107,7 @@ def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool def validate_services(config: Config, integration: Integration) -> None: """Validate services.""" try: - data = load_yaml(str(integration.path / "services.yaml")) + data = load_yaml_dict(str(integration.path / "services.yaml")) except FileNotFoundError: # Find if integration uses services has_services = grep_dir( @@ -122,7 +122,7 @@ def validate_services(config: Config, integration: Integration) -> None: ) return except HomeAssistantError: - integration.add_error("services", "Unable to load services.yaml") + integration.add_error("services", "Invalid services.yaml") return try: diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 6d83b00517d..359303c51fd 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1102,7 +1102,7 @@ async def test_reload_automation_when_blueprint_changes( autospec=True, return_value=config, ), patch( - "homeassistant.components.blueprint.models.yaml.load_yaml", + "homeassistant.components.blueprint.models.yaml.load_yaml_dict", autospec=True, return_value=blueprint_config, ): diff --git a/tests/components/lovelace/test_cast.py b/tests/components/lovelace/test_cast.py index e67ab3f841a..4181d73c4d3 100644 --- a/tests/components/lovelace/test_cast.py +++ b/tests/components/lovelace/test_cast.py @@ -44,7 +44,7 @@ async def mock_yaml_dashboard(hass): ) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={ "title": "YAML Title", "views": [ diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 05bc7f372b8..a772b37f047 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -141,7 +141,7 @@ async def test_lovelace_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json({"id": 7, "type": "lovelace/config"}) @@ -154,7 +154,7 @@ async def test_lovelace_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json({"id": 8, "type": "lovelace/config", "force": True}) @@ -245,7 +245,7 @@ async def test_dashboard_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json( @@ -260,7 +260,7 @@ async def test_dashboard_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json( diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index f7830f03ed6..4a280eccfda 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -38,7 +38,7 @@ async def test_yaml_resources_backwards( ) -> None: """Test defining resources in YAML ll config (legacy).""" with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"resources": RESOURCE_EXAMPLES}, ): assert await async_setup_component( diff --git a/tests/components/lovelace/test_system_health.py b/tests/components/lovelace/test_system_health.py index 7a39bc4605d..72e7adb3a13 100644 --- a/tests/components/lovelace/test_system_health.py +++ b/tests/components/lovelace/test_system_health.py @@ -39,7 +39,7 @@ async def test_system_health_info_yaml(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) await hass.async_block_till_done() with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"views": [{"cards": []}]}, ): info = await get_system_health_info(hass, "lovelace") diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index 27f6d7a8e51..12af639b251 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -57,7 +57,7 @@ async def test_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index 9cbf8768dd5..74573e2185b 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -60,7 +60,7 @@ async def test_webostv_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 25553489b4e..26b9459cfc2 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -272,7 +272,7 @@ async def test_zwave_js_value_updated( clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) @@ -834,7 +834,7 @@ async def test_zwave_js_event( clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index c4e5c58e235..1e31d8c6955 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -134,6 +134,7 @@ def test_include_yaml( [ ({"/test/one.yaml": "one", "/test/two.yaml": "two"}, ["one", "two"]), ({"/test/one.yaml": "1", "/test/two.yaml": "2"}, [1, 2]), + ({"/test/one.yaml": "1", "/test/two.yaml": None}, [1]), ], ) def test_include_dir_list( @@ -190,6 +191,10 @@ def test_include_dir_list_recursive( {"/test/first.yaml": "1", "/test/second.yaml": "2"}, {"first": 1, "second": 2}, ), + ( + {"/test/first.yaml": "1", "/test/second.yaml": None}, + {"first": 1}, + ), ], ) def test_include_dir_named( @@ -249,6 +254,10 @@ def test_include_dir_named_recursive( {"/test/first.yaml": "- 1", "/test/second.yaml": "- 2\n- 3"}, [1, 2, 3], ), + ( + {"/test/first.yaml": "- 1", "/test/second.yaml": None}, + [1], + ), ], ) def test_include_dir_merge_list( @@ -311,6 +320,13 @@ def test_include_dir_merge_list_recursive( }, {"key1": 1, "key2": 2, "key3": 3}, ), + ( + { + "/test/first.yaml": "key1: 1", + "/test/second.yaml": None, + }, + {"key1": 1}, + ), ], ) def test_include_dir_merge_named( @@ -686,3 +702,20 @@ def test_string_used_as_vol_schema(try_both_loaders) -> None: schema({"key_1": "value_1", "key_2": "value_2"}) with pytest.raises(vol.Invalid): schema({"key_1": "value_2", "key_2": "value_1"}) + + +@pytest.mark.parametrize( + ("hass_config_yaml", "expected_data"), [("", {}), ("bla:", {"bla": None})] +) +def test_load_yaml_dict( + try_both_loaders, mock_hass_config_yaml: None, expected_data: Any +) -> None: + """Test item without a key.""" + assert yaml.load_yaml_dict(YAML_CONFIG_FILE) == expected_data + + +@pytest.mark.parametrize("hass_config_yaml", ["abc", "123", "[]"]) +def test_load_yaml_dict_fail(try_both_loaders, mock_hass_config_yaml: None) -> None: + """Test item without a key.""" + with pytest.raises(yaml_loader.YamlTypeError): + yaml_loader.load_yaml_dict(YAML_CONFIG_FILE) From 428c184c75a94ef91f9d9c222194b40c5371da90 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 18:17:56 +0100 Subject: [PATCH 500/643] Improve yamaha tests (#105077) --- tests/components/yamaha/test_media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index af393339eba..6fc3259a4c0 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -16,6 +16,7 @@ CONFIG = {"media_player": {"platform": "yamaha", "host": "127.0.0.1"}} def _create_zone_mock(name, url): zone = MagicMock() zone.ctrl_url = url + zone.surround_programs = [] zone.zone = name return zone From b6245c834d5f9c51db4a984f3e5a5f6b6c66e0c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 07:19:02 -1000 Subject: [PATCH 501/643] Move local bluetooth scanner code into habluetooth library (#104970) --- .../components/bluetooth/__init__.py | 14 +- homeassistant/components/bluetooth/api.py | 8 +- homeassistant/components/bluetooth/models.py | 7 - .../bluetooth/passive_update_processor.py | 8 +- homeassistant/components/bluetooth/scanner.py | 390 ------------------ .../bluetooth/update_coordinator.py | 4 +- homeassistant/components/bluetooth/util.py | 9 - tests/components/bluetooth/conftest.py | 10 +- .../components/bluetooth/test_diagnostics.py | 223 +++++----- tests/components/bluetooth/test_init.py | 26 +- tests/components/bluetooth/test_scanner.py | 69 ++-- tests/conftest.py | 4 +- 12 files changed, 180 insertions(+), 592 deletions(-) delete mode 100644 homeassistant/components/bluetooth/scanner.py diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index a0eb263757a..99bb02054e7 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -21,7 +21,12 @@ from bluetooth_adapters import ( adapter_unique_name, get_adapters, ) -from habluetooth import HaBluetoothConnector +from habluetooth import ( + BluetoothScanningMode, + HaBluetoothConnector, + HaScanner, + ScannerStartError, +) from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb @@ -76,10 +81,9 @@ from .const import ( LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, ) -from .manager import BluetoothManager +from .manager import MONOTONIC_TIME, BluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher -from .models import BluetoothCallback, BluetoothChange, BluetoothScanningMode -from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError +from .models import BluetoothCallback, BluetoothChange from .storage import BluetoothStorage if TYPE_CHECKING: @@ -281,7 +285,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE new_info_callback = async_get_advertisement_callback(hass) manager: BluetoothManager = hass.data[DATA_MANAGER] - scanner = HaScanner(hass, mode, adapter, address, new_info_callback) + scanner = HaScanner(mode, adapter, address, new_info_callback) try: scanner.async_setup() except RuntimeError as err: diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 9d24428e3d2..897402d4049 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -9,6 +9,7 @@ from asyncio import Future from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast +from habluetooth import BluetoothScanningMode from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback @@ -17,12 +18,7 @@ from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import DATA_MANAGER from .manager import BluetoothManager from .match import BluetoothCallbackMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - ProcessAdvertisementCallback, -) +from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback from .wrappers import HaBleakScannerWrapper if TYPE_CHECKING: diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 48ba021cd6c..a35c5be6daf 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -17,13 +17,6 @@ MANAGER: BluetoothManager | None = None MONOTONIC_TIME: Final = monotonic_time_coarse -class BluetoothScanningMode(Enum): - """The mode of scanning for bluetooth devices.""" - - PASSIVE = "passive" - ACTIVE = "active" - - BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 7dd39c14039..8da0d2c462b 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -7,6 +7,8 @@ from functools import cache import logging from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast +from habluetooth import BluetoothScanningMode + from homeassistant import config_entries from homeassistant.const import ( ATTR_CONNECTIONS, @@ -33,11 +35,7 @@ if TYPE_CHECKING: from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .models import ( - BluetoothChange, - BluetoothScanningMode, - BluetoothServiceInfoBleak, - ) + from .models import BluetoothChange, BluetoothServiceInfoBleak STORAGE_KEY = "bluetooth.passive_update_processor" STORAGE_VERSION = 1 diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py deleted file mode 100644 index 95733039df4..00000000000 --- a/homeassistant/components/bluetooth/scanner.py +++ /dev/null @@ -1,390 +0,0 @@ -"""The bluetooth integration.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -import logging -import platform -from typing import Any - -import bleak -from bleak import BleakError -from bleak.assigned_numbers import AdvertisementDataType -from bleak.backends.bluezdbus.advertisement_monitor import OrPattern -from bleak.backends.bluezdbus.scanner import BlueZScannerArgs -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback -from bleak_retry_connector import restore_discoveries -from bluetooth_adapters import DEFAULT_ADDRESS -from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME -from dbus_fast import InvalidMessageError - -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.package import is_docker_env - -from .base_scanner import BaseHaScanner -from .const import ( - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, - SOURCE_LOCAL, - START_TIMEOUT, -) -from .models import BluetoothScanningMode, BluetoothServiceInfoBleak -from .util import async_reset_adapter - -OriginalBleakScanner = bleak.BleakScanner - -# or_patterns is a workaround for the fact that passive scanning -# needs at least one matcher to be set. The below matcher -# will match all devices. -PASSIVE_SCANNER_ARGS = BlueZScannerArgs( - or_patterns=[ - OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), - OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), - ] -) -_LOGGER = logging.getLogger(__name__) - - -# If the adapter is in a stuck state the following errors are raised: -NEED_RESET_ERRORS = [ - "org.bluez.Error.Failed", - "org.bluez.Error.InProgress", - "org.bluez.Error.NotReady", - "not found", -] - -# When the adapter is still initializing, the scanner will raise an exception -# with org.freedesktop.DBus.Error.UnknownObject -WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"] -ADAPTER_INIT_TIME = 1.5 - -START_ATTEMPTS = 3 - -SCANNING_MODE_TO_BLEAK = { - BluetoothScanningMode.ACTIVE: "active", - BluetoothScanningMode.PASSIVE: "passive", -} - -# The minimum number of seconds to know -# the adapter has not had advertisements -# and we already tried to restart the scanner -# without success when the first time the watch -# dog hit the failure path. -SCANNER_WATCHDOG_MULTIPLE = ( - SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds() -) - - -class ScannerStartError(HomeAssistantError): - """Error to indicate that the scanner failed to start.""" - - -def create_bleak_scanner( - detection_callback: AdvertisementDataCallback, - scanning_mode: BluetoothScanningMode, - adapter: str | None, -) -> bleak.BleakScanner: - """Create a Bleak scanner.""" - scanner_kwargs: dict[str, Any] = { - "detection_callback": detection_callback, - "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], - } - system = platform.system() - if system == "Linux": - # Only Linux supports multiple adapters - if adapter: - scanner_kwargs["adapter"] = adapter - if scanning_mode == BluetoothScanningMode.PASSIVE: - scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS - elif system == "Darwin": - # We want mac address on macOS - scanner_kwargs["cb"] = {"use_bdaddr": True} - _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) - - try: - return OriginalBleakScanner(**scanner_kwargs) - except (FileNotFoundError, BleakError) as ex: - raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex - - -class HaScanner(BaseHaScanner): - """Operate and automatically recover a BleakScanner. - - Multiple BleakScanner can be used at the same time - if there are multiple adapters. This is only useful - if the adapters are not located physically next to each other. - - Example use cases are usbip, a long extension cable, usb to bluetooth - over ethernet, usb over ethernet, etc. - """ - - scanner: bleak.BleakScanner - - def __init__( - self, - hass: HomeAssistant, - mode: BluetoothScanningMode, - adapter: str, - address: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - ) -> None: - """Init bluetooth discovery.""" - self.mac_address = address - source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL - super().__init__(source, adapter) - self.connectable = True - self.mode = mode - self._start_stop_lock = asyncio.Lock() - self._new_info_callback = new_info_callback - self.scanning = False - self.hass = hass - self._last_detection = 0.0 - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - return self.scanner.discovered_devices - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self.scanner.discovered_devices_and_advertisement_data - - @hass_callback - def async_setup(self) -> CALLBACK_TYPE: - """Set up the scanner.""" - super().async_setup() - self.scanner = create_bleak_scanner( - self._async_detection_callback, self.mode, self.adapter - ) - return self._unsetup - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - base_diag = await super().async_diagnostics() - return base_diag | { - "adapter": self.adapter, - } - - @hass_callback - def _async_detection_callback( - self, - device: BLEDevice, - advertisement_data: AdvertisementData, - ) -> None: - """Call the callback when an advertisement is received. - - Currently this is used to feed the callbacks into the - central manager. - """ - callback_time = MONOTONIC_TIME() - if ( - advertisement_data.local_name - or advertisement_data.manufacturer_data - or advertisement_data.service_data - or advertisement_data.service_uuids - ): - # Don't count empty advertisements - # as the adapter is in a failure - # state if all the data is empty. - self._last_detection = callback_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=advertisement_data.local_name or device.name or device.address, - address=device.address, - rssi=advertisement_data.rssi, - manufacturer_data=advertisement_data.manufacturer_data, - service_data=advertisement_data.service_data, - service_uuids=advertisement_data.service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=True, - time=callback_time, - ) - ) - - async def async_start(self) -> None: - """Start bluetooth scanner.""" - async with self._start_stop_lock: - await self._async_start() - - async def _async_start(self) -> None: - """Start bluetooth scanner under the lock.""" - for attempt in range(START_ATTEMPTS): - _LOGGER.debug( - "%s: Starting bluetooth discovery attempt: (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - try: - async with asyncio.timeout(START_TIMEOUT): - await self.scanner.start() # type: ignore[no-untyped-call] - except InvalidMessageError as ex: - _LOGGER.debug( - "%s: Invalid DBus message received: %s", - self.name, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Invalid DBus message received: {ex}; " - "try restarting `dbus`" - ) from ex - except BrokenPipeError as ex: - _LOGGER.debug( - "%s: DBus connection broken: %s", self.name, ex, exc_info=True - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth`, `dbus`, and finally the docker container" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth` and `dbus`" - ) from ex - except FileNotFoundError as ex: - _LOGGER.debug( - "%s: FileNotFoundError while starting bluetooth: %s", - self.name, - ex, - exc_info=True, - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus service not found; docker config may " - "be missing `-v /run/dbus:/run/dbus:ro`: {ex}" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus service not found; make sure the DBus socket " - f"is available to Home Assistant: {ex}" - ) from ex - except asyncio.TimeoutError as ex: - if attempt == 0: - await self._async_reset_adapter() - continue - raise ScannerStartError( - f"{self.name}: Timed out starting Bluetooth after" - f" {START_TIMEOUT} seconds" - ) from ex - except BleakError as ex: - error_str = str(ex) - if attempt == 0: - if any( - needs_reset_error in error_str - for needs_reset_error in NEED_RESET_ERRORS - ): - await self._async_reset_adapter() - continue - if attempt != START_ATTEMPTS - 1: - # If we are not out of retry attempts, and the - # adapter is still initializing, wait a bit and try again. - if any( - wait_error in error_str - for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS - ): - _LOGGER.debug( - "%s: Waiting for adapter to initialize; attempt (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - await asyncio.sleep(ADAPTER_INIT_TIME) - continue - - _LOGGER.debug( - "%s: BleakError while starting bluetooth; attempt: (%s/%s): %s", - self.name, - attempt + 1, - START_ATTEMPTS, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Failed to start Bluetooth: {ex}" - ) from ex - - # Everything is fine, break out of the loop - break - - self.scanning = True - self._async_setup_scanner_watchdog() - await restore_discoveries(self.scanner, self.adapter) - - @hass_callback - def _async_scanner_watchdog(self) -> None: - """Check if the scanner is running.""" - if not self._async_watchdog_triggered(): - return - if self._start_stop_lock.locked(): - _LOGGER.debug( - "%s: Scanner is already restarting, deferring restart", - self.name, - ) - return - _LOGGER.info( - "%s: Bluetooth scanner has gone quiet for %ss, restarting", - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - # Immediately mark the scanner as not scanning - # since the restart task will have to wait for the lock - self.scanning = False - self.hass.async_create_task(self._async_restart_scanner()) - - async def _async_restart_scanner(self) -> None: - """Restart the scanner.""" - async with self._start_stop_lock: - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - # Stop the scanner but not the watchdog - # since we want to try again later if it's still quiet - await self._async_stop_scanner() - # If there have not been any valid advertisements, - # or the watchdog has hit the failure path multiple times, - # do the reset. - if ( - self._start_time == self._last_detection - or time_since_last_detection > SCANNER_WATCHDOG_MULTIPLE - ): - await self._async_reset_adapter() - try: - await self._async_start() - except ScannerStartError as ex: - _LOGGER.exception( - "%s: Failed to restart Bluetooth scanner: %s", - self.name, - ex, - ) - - async def _async_reset_adapter(self) -> None: - """Reset the adapter.""" - # There is currently nothing the user can do to fix this - # so we log at debug level. If we later come up with a repair - # strategy, we will change this to raise a repair issue as well. - _LOGGER.debug("%s: adapter stopped responding; executing reset", self.name) - result = await async_reset_adapter(self.adapter, self.mac_address) - _LOGGER.debug("%s: adapter reset result: %s", self.name, result) - - async def async_stop(self) -> None: - """Stop bluetooth scanner.""" - async with self._start_stop_lock: - self._async_stop_scanner_watchdog() - await self._async_stop_scanner() - - async def _async_stop_scanner(self) -> None: - """Stop bluetooth discovery under the lock.""" - self.scanning = False - _LOGGER.debug("%s: Stopping bluetooth discovery", self.name) - try: - await self.scanner.stop() # type: ignore[no-untyped-call] - except BleakError as ex: - # This is not fatal, and they may want to reload - # the config entry to restart the scanner if they - # change the bluetooth dongle. - _LOGGER.error("%s: Error stopping scanner: %s", self.name, ex) diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 295e84d4481..2d495a0659c 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -4,6 +4,8 @@ from __future__ import annotations from abc import ABC, abstractmethod import logging +from habluetooth import BluetoothScanningMode + from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from .api import ( @@ -13,7 +15,7 @@ from .api import ( async_track_unavailable, ) from .match import BluetoothCallbackMatcher -from .models import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak +from .models import BluetoothChange, BluetoothServiceInfoBleak class BasePassiveBluetoothCoordinator(ABC): diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index f276b6b51e5..d531e46f911 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -2,7 +2,6 @@ from __future__ import annotations from bluetooth_adapters import BluetoothAdapters -from bluetooth_auto_recovery import recover_adapter from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import callback @@ -69,11 +68,3 @@ def async_load_history_from_system( connectable_loaded_history[address] = service_info return all_loaded_history, connectable_loaded_history - - -async def async_reset_adapter(adapter: str | None, mac_address: str) -> bool | None: - """Reset the adapter.""" - if adapter and adapter.startswith("hci"): - adapter_id = int(adapter[3:]) - return await recover_adapter(adapter_id, mac_address) - return False diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 5f166a3fca2..4ec6c4e5388 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -50,7 +50,7 @@ def macos_adapter(): "homeassistant.components.bluetooth.platform.system", return_value="Darwin", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Darwin", ), patch( "bluetooth_adapters.systems.platform.system", @@ -76,7 +76,7 @@ def no_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -97,7 +97,7 @@ def one_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -128,7 +128,7 @@ def two_adapters_fixture(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" @@ -168,7 +168,7 @@ def one_adapter_old_bluez(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 8625283266e..a69c26a16ea 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -3,6 +3,7 @@ from unittest.mock import ANY, MagicMock, patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import HaScanner from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -25,6 +26,21 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +class FakeHaScanner(HaScanner): + """Fake HaScanner.""" + + @property + def discovered_devices_and_advertisement_data(self): + """Return the discovered devices and advertisement data.""" + return { + "44:44:33:11:23:45": ( + generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), + generate_advertisement_data(local_name="x"), + ) + } + + +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -38,15 +54,8 @@ async def test_diagnostics( # because we cannot import the scanner class directly without it throwing an # error if the test is not running on linux since we won't have the correct # deps installed when testing on MacOS. + with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - generate_advertisement_data(local_name="x"), - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Linux", ), patch( @@ -88,25 +97,25 @@ async def test_diagnostics( "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 2, }, }, "dbus": { @@ -126,63 +135,42 @@ async def test_diagnostics( } }, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5, "hci1": 2}, - "allocations_by_adapter": {"hci0": [], "hci1": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 2, }, }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {}, "timings": {}, }, - "connectable_history": [], "all_history": [], + "connectable_history": [], "scanners": [ { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [ - { - "address": "44:44:33:11:23:45", - "advertisement_data": [ - "x", - {}, - {}, - [], - -127, - -127, - [[]], - ], - "details": None, - "name": "x", - "rssi": -127, - } - ], + "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", @@ -216,7 +204,7 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "adapter": "hci1", @@ -243,13 +231,19 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:02", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5, "hci1": 2}, + "allocations_by_adapter": {"hci0": [], "hci1": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_macos( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -269,14 +263,6 @@ async def test_diagnostics_macos( ) with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - switchbot_adv, - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Darwin", ), patch( @@ -297,70 +283,36 @@ async def test_diagnostics_macos( inject_advertisement(hass, switchbot_device, switchbot_adv) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) - assert diag == { "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "manager": { - "slot_manager": { - "adapter_slots": {"Core Bluetooth": 5}, - "allocations_by_adapter": {"Core Bluetooth": []}, - "manager": False, - }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "local"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, - "connectable_history": [ - { - "address": "44:44:33:11:23:45", - "advertisement": [ - "wohand", - {"1": {"__type": "", "repr": "b'\\x01'"}}, - {}, - [], - -127, - -127, - [[]], - ], - "device": { - "__type": "", - "repr": "BLEDevice(44:44:33:11:23:45, wohand)", - }, - "connectable": True, - "manufacturer_data": { - "1": {"__type": "", "repr": "b'\\x01'"} - }, - "name": "wohand", - "rssi": -127, - "service_data": {}, - "service_uuids": [], - "source": "local", - "time": ANY, - } - ], "all_history": [ { "address": "44:44:33:11:23:45", @@ -373,11 +325,39 @@ async def test_diagnostics_macos( -127, [[]], ], + "connectable": True, "device": { "__type": "", "repr": "BLEDevice(44:44:33:11:23:45, wohand)", }, + "manufacturer_data": { + "1": {"__type": "", "repr": "b'\\x01'"} + }, + "name": "wohand", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": "local", + "time": ANY, + } + ], + "connectable_history": [ + { + "address": "44:44:33:11:23:45", + "advertisement": [ + "wohand", + {"1": {"__type": "", "repr": "b'\\x01'"}}, + {}, + [], + -127, + -127, + [[]], + ], "connectable": True, + "device": { + "__type": "", + "repr": "BLEDevice(44:44:33:11:23:45, wohand)", + }, "manufacturer_data": { "1": {"__type": "", "repr": "b'\\x01'"} }, @@ -396,13 +376,8 @@ async def test_diagnostics_macos( { "address": "44:44:33:11:23:45", "advertisement_data": [ - "wohand", - { - "1": { - "__type": "", - "repr": "b'\\x01'", - } - }, + "x", + {}, {}, [], -127, @@ -420,13 +395,19 @@ async def test_diagnostics_macos( "scanning": True, "source": "Core Bluetooth", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", } ], + "slot_manager": { + "adapter_slots": {"Core Bluetooth": 5}, + "allocations_by_adapter": {"Core Bluetooth": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_remote_adapter( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -497,17 +478,12 @@ async def test_diagnostics_remote_adapter( "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", - "sw_version": "homeassistant", + "sw_version": ANY, "vendor_id": "cc01", } }, "dbus": {}, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5}, - "allocations_by_adapter": {"hci0": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -521,8 +497,8 @@ async def test_diagnostics_remote_adapter( } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "esp32"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, @@ -596,19 +572,34 @@ async def test_diagnostics_remote_adapter( }, { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [], + "discovered_devices_and_advertisement_data": [ + { + "address": "44:44:33:11:23:45", + "advertisement_data": [ + "x", + {}, + {}, + [], + -127, + -127, + [[]], + ], + "details": None, + "name": "x", + "rssi": -127, + } + ], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "connectable": False, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, - "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, "discovered_devices_and_advertisement_data": [ { "address": "44:44:33:11:23:45", @@ -639,11 +630,17 @@ async def test_diagnostics_remote_adapter( "name": "esp32", "scanning": True, "source": "esp32", - "storage": None, - "type": "FakeScanner", "start_time": ANY, + "storage": None, + "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, + "type": "FakeScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5}, + "allocations_by_adapter": {"hci0": []}, + "manager": False, + }, }, } diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index b24bb97e1e3..63ff735ca43 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import ANY, MagicMock, Mock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import scanner import pytest from homeassistant.components import bluetooth @@ -17,7 +18,6 @@ from homeassistant.components.bluetooth import ( async_process_advertisements, async_rediscover_address, async_track_unavailable, - scanner, ) from homeassistant.components.bluetooth.const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, @@ -107,7 +107,7 @@ async def test_setup_and_stop_passive( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockPassiveBleakScanner, ): assert await async_setup_component( @@ -158,7 +158,7 @@ async def test_setup_and_stop_old_bluez( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( @@ -185,7 +185,7 @@ async def test_setup_and_stop_no_bluetooth( {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} ] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=BleakError, ) as mock_ha_bleak_scanner, patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -206,7 +206,7 @@ async def test_setup_and_stop_broken_bluetooth( """Test we fail gracefully when bluetooth/dbus is broken.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -231,7 +231,7 @@ async def test_setup_and_stop_broken_bluetooth_hanging( await asyncio.sleep(1) with patch.object(scanner, "START_TIMEOUT", 0), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=_mock_hang, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -251,7 +251,7 @@ async def test_setup_and_retry_adapter_not_yet_available( """Test we retry if the adapter is not yet available.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -267,14 +267,14 @@ async def test_setup_and_retry_adapter_not_yet_available( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -286,7 +286,7 @@ async def test_no_race_during_manual_reload_in_retry_state( """Test we can successfully reload when the entry is in a retry state.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -302,7 +302,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -310,7 +310,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -322,7 +322,7 @@ async def test_calling_async_discovered_devices_no_bluetooth( """Test we fail gracefully when asking for discovered devices and there is no blueooth.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=FileNotFoundError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index b660be74aa9..c33bfd6db84 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -14,7 +14,6 @@ from homeassistant.components.bluetooth.const import ( SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) -from homeassistant.components.bluetooth.scanner import NEED_RESET_ERRORS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -30,6 +29,14 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed +# If the adapter is in a stuck state the following errors are raised: +NEED_RESET_ERRORS = [ + "org.bluez.Error.Failed", + "org.bluez.Error.InProgress", + "org.bluez.Error.NotReady", + "not found", +] + async def test_config_entry_can_be_reloaded_when_stop_raises( hass: HomeAssistant, @@ -42,7 +49,7 @@ async def test_config_entry_can_be_reloaded_when_stop_raises( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", side_effect=BleakError, ): await hass.config_entries.async_reload(entry.entry_id) @@ -57,10 +64,8 @@ async def test_dbus_socket_missing_in_container( ) -> None: """Test we handle dbus being missing in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -79,10 +84,8 @@ async def test_dbus_socket_missing( ) -> None: """Test we handle dbus being missing.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -101,10 +104,8 @@ async def test_dbus_broken_pipe_in_container( ) -> None: """Test we handle dbus broken pipe in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -124,10 +125,8 @@ async def test_dbus_broken_pipe( ) -> None: """Test we handle dbus broken pipe.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -148,7 +147,7 @@ async def test_invalid_dbus_message( """Test we handle invalid dbus message.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=InvalidMessageError, ): await async_setup_with_one_adapter(hass) @@ -168,10 +167,10 @@ async def test_adapter_needs_reset_at_start( """Test we cycle the adapter when it needs a restart.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=[BleakError(error), None], ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -216,7 +215,7 @@ async def test_recovery_from_dbus_restart( return mock_discovered with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): await async_setup_with_one_adapter(hass) @@ -306,7 +305,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -343,7 +342,7 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -395,7 +394,7 @@ async def test_adapter_scanner_fails_to_start_first_time( "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -432,7 +431,7 @@ async def test_adapter_scanner_fails_to_start_first_time( + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -448,7 +447,7 @@ async def test_adapter_scanner_fails_to_start_first_time( + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -503,16 +502,16 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch( "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -554,17 +553,15 @@ async def test_restart_takes_longer_than_watchdog_time( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch( "habluetooth.base_scanner.MONOTONIC_TIME", return_value=start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, - ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True - ): + ), patch("habluetooth.util.recover_adapter", return_value=True): await async_setup_with_one_adapter(hass) assert called_start == 1 @@ -617,7 +614,7 @@ async def test_setup_and_stop_macos( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( diff --git a/tests/conftest.py b/tests/conftest.py index fcd8e8b73a9..4d0e2565164 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1574,14 +1574,14 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: # Late imports to avoid loading bleak unless we need it # pylint: disable-next=import-outside-toplevel - from homeassistant.components.bluetooth import scanner as bluetooth_scanner + from habluetooth import scanner as bluetooth_scanner # We need to drop the stop method from the object since we patched # out start and this fixture will expire before the stop method is called # when EVENT_HOMEASSISTANT_STOP is fired. bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ) as mock_bleak_scanner_start: yield mock_bleak_scanner_start From dc17780e5baab14f5cd5f6a6ad9ab54102042fc8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 5 Dec 2023 18:52:22 +0100 Subject: [PATCH 502/643] Remove device from known_devices upon import in ping device tracker (#105009) Co-authored-by: Joost Lekkerkerker --- .../components/device_tracker/legacy.py | 13 +++ .../components/ping/device_tracker.py | 100 +++++++++++++----- .../components/device_tracker/test_legacy.py | 44 ++++++++ tests/components/ping/test_device_tracker.py | 41 ++++++- 4 files changed, 169 insertions(+), 29 deletions(-) create mode 100644 tests/components/device_tracker/test_legacy.py diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 264926a65bf..a17972526cf 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -1036,6 +1036,19 @@ def update_config(path: str, dev_id: str, device: Device) -> None: out.write(dump(device_config)) +def remove_device_from_config(hass: HomeAssistant, device_id: str) -> None: + """Remove device from YAML configuration file.""" + path = hass.config.path(YAML_DEVICES) + devices = load_yaml_config_file(path) + devices.pop(device_id) + dumped = dump(devices) + + with open(path, "r+", encoding="utf8") as out: + out.seek(0) + out.truncate() + out.write(dumped) + + def get_gravatar_for_email(email: str) -> str: """Return an 80px Gravatar for the given email address. diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index ceff1b2e124..417659aad5c 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol @@ -11,9 +12,20 @@ from homeassistant.components.device_tracker import ( ScannerEntity, SourceType, ) +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + remove_device_from_config, +) +from homeassistant.config import load_yaml_config_file from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.const import ( + CONF_HOST, + CONF_HOSTS, + CONF_NAME, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -42,34 +54,66 @@ async def async_setup_scanner( ) -> bool: """Legacy init: import via config flow.""" - for dev_name, dev_host in config[CONF_HOSTS].items(): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_IMPORTED_BY: "device_tracker", - CONF_NAME: dev_name, - CONF_HOST: dev_host, - CONF_PING_COUNT: config[CONF_PING_COUNT], - }, - ) + async def _run_import(_: Event) -> None: + """Delete devices from known_device.yaml and import them via config flow.""" + _LOGGER.debug( + "Home Assistant successfully started, importing ping device tracker config entries now" ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Ping", - }, - ) + devices: dict[str, dict[str, Any]] = {} + try: + devices = await hass.async_add_executor_job( + load_yaml_config_file, hass.config.path(YAML_DEVICES) + ) + except (FileNotFoundError, HomeAssistantError): + _LOGGER.debug( + "No valid known_devices.yaml found, " + "skip removal of devices from known_devices.yaml" + ) + + for dev_name, dev_host in config[CONF_HOSTS].items(): + if dev_name in devices: + await hass.async_add_executor_job( + remove_device_from_config, hass, dev_name + ) + _LOGGER.debug("Removed device %s from known_devices.yaml", dev_name) + + if not hass.states.async_available(f"device_tracker.{dev_name}"): + hass.states.async_remove(f"device_tracker.{dev_name}") + + # run import after everything has been cleaned up + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_IMPORTED_BY: "device_tracker", + CONF_NAME: dev_name, + CONF_HOST: dev_host, + CONF_PING_COUNT: config[CONF_PING_COUNT], + }, + ) + ) + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ping", + }, + ) + + # delay the import until after Home Assistant has started and everything has been initialized, + # as the legacy device tracker entities will be restored after the legacy device tracker platforms + # have been set up, so we can only remove the entities from the state machine then + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import) return True diff --git a/tests/components/device_tracker/test_legacy.py b/tests/components/device_tracker/test_legacy.py new file mode 100644 index 00000000000..d7a2f33c23b --- /dev/null +++ b/tests/components/device_tracker/test_legacy.py @@ -0,0 +1,44 @@ +"""Tests for the legacy device tracker component.""" +from unittest.mock import mock_open, patch + +from homeassistant.components.device_tracker import legacy +from homeassistant.core import HomeAssistant +from homeassistant.util.yaml import dump + +from tests.common import patch_yaml_files + + +def test_remove_device_from_config(hass: HomeAssistant): + """Test the removal of a device from a config.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + "test2": { + "hide_if_away": True, + "mac": "00:ab:cd:33:44:55", + "name": "Test2", + "picture": "/local/test2.png", + "track": True, + }, + } + mopen = mock_open() + + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + with patch_yaml_files(files, True), patch( + "homeassistant.components.device_tracker.legacy.open", mopen + ): + legacy.remove_device_from_config(hass, "test") + + mopen().write.assert_called_once_with( + "test2:\n" + " hide_if_away: true\n" + " mac: 00:ab:cd:33:44:55\n" + " name: Test2\n" + " picture: /local/test2.png\n" + " track: true\n" + ) diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index b6cc6b42912..5f5bb2132c1 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,13 +1,17 @@ """Test the binary sensor platform of ping.""" +from unittest.mock import patch import pytest +from homeassistant.components.device_tracker import legacy from homeassistant.components.ping.const import DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component +from homeassistant.util.yaml import dump -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, patch_yaml_files @pytest.mark.usefixtures("setup_integration") @@ -56,7 +60,42 @@ async def test_import_issue_creation( ) await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + issue = issue_registry.async_get_issue( HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" ) assert issue + + +async def test_import_delete_known_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +): + """Test if import deletes known devices.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + } + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + + with patch_yaml_files(files, True), patch( + "homeassistant.components.ping.device_tracker.remove_device_from_config" + ) as remove_device_from_config: + await async_setup_component( + hass, + "device_tracker", + {"device_tracker": {"platform": "ping", "hosts": {"test": "10.10.10.10"}}}, + ) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(remove_device_from_config.mock_calls) == 1 From dd37205a42577fe12b510399241c68d3ceb87c51 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 5 Dec 2023 18:52:52 +0100 Subject: [PATCH 503/643] Update frontend to 20231205.0 (#105081) --- 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 e254eda0689..08eb0f0a424 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==20231204.0"] + "requirements": ["home-assistant-frontend==20231205.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e9055eddebd..b36fe975184 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ habluetooth==0.6.1 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 26b1f98f5a6..2653d1ac6fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1024,7 +1024,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b1e923b330..dd15a878212 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -811,7 +811,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From 4018a285101d92280737bef5f552b7599f0d60c2 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 5 Dec 2023 18:52:22 +0100 Subject: [PATCH 504/643] Remove device from known_devices upon import in ping device tracker (#105009) Co-authored-by: Joost Lekkerkerker --- .../components/device_tracker/legacy.py | 13 +++ .../components/ping/device_tracker.py | 100 +++++++++++++----- .../components/device_tracker/test_legacy.py | 44 ++++++++ tests/components/ping/test_device_tracker.py | 41 ++++++- 4 files changed, 169 insertions(+), 29 deletions(-) create mode 100644 tests/components/device_tracker/test_legacy.py diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index f18f7984e1e..c931d256689 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -1033,6 +1033,19 @@ def update_config(path: str, dev_id: str, device: Device) -> None: out.write(dump(device_config)) +def remove_device_from_config(hass: HomeAssistant, device_id: str) -> None: + """Remove device from YAML configuration file.""" + path = hass.config.path(YAML_DEVICES) + devices = load_yaml_config_file(path) + devices.pop(device_id) + dumped = dump(devices) + + with open(path, "r+", encoding="utf8") as out: + out.seek(0) + out.truncate() + out.write(dumped) + + def get_gravatar_for_email(email: str) -> str: """Return an 80px Gravatar for the given email address. diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index ceff1b2e124..417659aad5c 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol @@ -11,9 +12,20 @@ from homeassistant.components.device_tracker import ( ScannerEntity, SourceType, ) +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + remove_device_from_config, +) +from homeassistant.config import load_yaml_config_file from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.const import ( + CONF_HOST, + CONF_HOSTS, + CONF_NAME, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -42,34 +54,66 @@ async def async_setup_scanner( ) -> bool: """Legacy init: import via config flow.""" - for dev_name, dev_host in config[CONF_HOSTS].items(): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_IMPORTED_BY: "device_tracker", - CONF_NAME: dev_name, - CONF_HOST: dev_host, - CONF_PING_COUNT: config[CONF_PING_COUNT], - }, - ) + async def _run_import(_: Event) -> None: + """Delete devices from known_device.yaml and import them via config flow.""" + _LOGGER.debug( + "Home Assistant successfully started, importing ping device tracker config entries now" ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Ping", - }, - ) + devices: dict[str, dict[str, Any]] = {} + try: + devices = await hass.async_add_executor_job( + load_yaml_config_file, hass.config.path(YAML_DEVICES) + ) + except (FileNotFoundError, HomeAssistantError): + _LOGGER.debug( + "No valid known_devices.yaml found, " + "skip removal of devices from known_devices.yaml" + ) + + for dev_name, dev_host in config[CONF_HOSTS].items(): + if dev_name in devices: + await hass.async_add_executor_job( + remove_device_from_config, hass, dev_name + ) + _LOGGER.debug("Removed device %s from known_devices.yaml", dev_name) + + if not hass.states.async_available(f"device_tracker.{dev_name}"): + hass.states.async_remove(f"device_tracker.{dev_name}") + + # run import after everything has been cleaned up + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_IMPORTED_BY: "device_tracker", + CONF_NAME: dev_name, + CONF_HOST: dev_host, + CONF_PING_COUNT: config[CONF_PING_COUNT], + }, + ) + ) + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ping", + }, + ) + + # delay the import until after Home Assistant has started and everything has been initialized, + # as the legacy device tracker entities will be restored after the legacy device tracker platforms + # have been set up, so we can only remove the entities from the state machine then + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import) return True diff --git a/tests/components/device_tracker/test_legacy.py b/tests/components/device_tracker/test_legacy.py new file mode 100644 index 00000000000..d7a2f33c23b --- /dev/null +++ b/tests/components/device_tracker/test_legacy.py @@ -0,0 +1,44 @@ +"""Tests for the legacy device tracker component.""" +from unittest.mock import mock_open, patch + +from homeassistant.components.device_tracker import legacy +from homeassistant.core import HomeAssistant +from homeassistant.util.yaml import dump + +from tests.common import patch_yaml_files + + +def test_remove_device_from_config(hass: HomeAssistant): + """Test the removal of a device from a config.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + "test2": { + "hide_if_away": True, + "mac": "00:ab:cd:33:44:55", + "name": "Test2", + "picture": "/local/test2.png", + "track": True, + }, + } + mopen = mock_open() + + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + with patch_yaml_files(files, True), patch( + "homeassistant.components.device_tracker.legacy.open", mopen + ): + legacy.remove_device_from_config(hass, "test") + + mopen().write.assert_called_once_with( + "test2:\n" + " hide_if_away: true\n" + " mac: 00:ab:cd:33:44:55\n" + " name: Test2\n" + " picture: /local/test2.png\n" + " track: true\n" + ) diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index b6cc6b42912..5f5bb2132c1 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,13 +1,17 @@ """Test the binary sensor platform of ping.""" +from unittest.mock import patch import pytest +from homeassistant.components.device_tracker import legacy from homeassistant.components.ping.const import DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component +from homeassistant.util.yaml import dump -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, patch_yaml_files @pytest.mark.usefixtures("setup_integration") @@ -56,7 +60,42 @@ async def test_import_issue_creation( ) await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + issue = issue_registry.async_get_issue( HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" ) assert issue + + +async def test_import_delete_known_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +): + """Test if import deletes known devices.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + } + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + + with patch_yaml_files(files, True), patch( + "homeassistant.components.ping.device_tracker.remove_device_from_config" + ) as remove_device_from_config: + await async_setup_component( + hass, + "device_tracker", + {"device_tracker": {"platform": "ping", "hosts": {"test": "10.10.10.10"}}}, + ) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(remove_device_from_config.mock_calls) == 1 From 30d529aab08e331ac480a188b40ea332566a12e9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 5 Dec 2023 18:52:52 +0100 Subject: [PATCH 505/643] Update frontend to 20231205.0 (#105081) --- 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 e254eda0689..08eb0f0a424 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==20231204.0"] + "requirements": ["home-assistant-frontend==20231205.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4f998e7a663..1b089a57104 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 home-assistant-intents==2023.11.29 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index de4086b5fb3..0d6a007c2be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a25d5b3c566..f30a4a90b58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231204.0 +home-assistant-frontend==20231205.0 # homeassistant.components.conversation home-assistant-intents==2023.11.29 From b0367d3d749477c021048f425c9f2683b4f09799 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Dec 2023 19:09:24 +0100 Subject: [PATCH 506/643] Bump version to 2023.12.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index ecb09a5d621..719b661e68f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 26114091715..b207c9f1aa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b3" +version = "2023.12.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From d4cbe89c2fb969ea0d542e01ea9115af0a559e7a Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 5 Dec 2023 19:14:13 +0100 Subject: [PATCH 507/643] Update energyzero lib to v2.0.0 (#105080) --- homeassistant/components/energyzero/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 9ef99173ffb..7b1588eeb54 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==1.0.0"] + "requirements": ["energyzero==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2653d1ac6fb..45be2b74637 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -759,7 +759,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==1.0.0 +energyzero==2.0.0 # homeassistant.components.enocean enocean==0.50 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd15a878212..1d0e94eaaee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -613,7 +613,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==1.0.0 +energyzero==2.0.0 # homeassistant.components.enocean enocean==0.50 From 1edfaed7befd65d5ab7bc0e078423826d5ec047b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Dec 2023 19:14:35 +0100 Subject: [PATCH 508/643] Improve raise contains mocks (#105078) Co-authored-by: J. Nick Koston --- tests/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/common.py b/tests/common.py index b2fa53d28fb..15498019b16 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1427,7 +1427,7 @@ ANY = _HA_ANY() def raise_contains_mocks(val: Any) -> None: """Raise for mocks.""" if isinstance(val, Mock): - raise TypeError + raise TypeError(val) if isinstance(val, dict): for dict_value in val.values(): From 3310f4c1304b25e049838673f20c13b3080b6c80 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 5 Dec 2023 19:17:52 +0100 Subject: [PATCH 509/643] Add significant Change support for weather (#104840) --- .../components/weather/significant_change.py | 175 +++++++++ .../weather/test_significant_change.py | 347 ++++++++++++++++++ 2 files changed, 522 insertions(+) create mode 100644 homeassistant/components/weather/significant_change.py create mode 100644 tests/components/weather/test_significant_change.py diff --git a/homeassistant/components/weather/significant_change.py b/homeassistant/components/weather/significant_change.py new file mode 100644 index 00000000000..bd6571a390e --- /dev/null +++ b/homeassistant/components/weather/significant_change.py @@ -0,0 +1,175 @@ +"""Helper to test significant Weather state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import check_absolute_change + +from .const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, +} + +VALID_CARDINAL_DIRECTIONS: list[str] = [ + "n", + "nne", + "ne", + "ene", + "e", + "ese", + "se", + "sse", + "s", + "ssw", + "sw", + "wsw", + "w", + "wnw", + "nw", + "nnw", +] + + +def _check_valid_float(value: str | int | float) -> bool: + """Check if given value is a valid float.""" + try: + float(value) + except ValueError: + return False + return True + + +def _cardinal_to_degrees(value: str | int | float | None) -> int | float | None: + """Translate a cardinal direction into azimuth angle (degrees).""" + if not isinstance(value, str): + return value + + try: + return float(360 / 16 * VALID_CARDINAL_DIRECTIONS.index(value.lower())) + except ValueError: + return None + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + # state changes are always significant + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name not in SIGNIFICANT_ATTRIBUTES: + continue + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + absolute_change: float | None = None + if attr_name == ATTR_WEATHER_WIND_BEARING: + old_attr_value = _cardinal_to_degrees(old_attr_value) + new_attr_value = _cardinal_to_degrees(new_attr_value) + + if new_attr_value is None or not _check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not _check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if attr_name in ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_TEMPERATURE, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_TEMPERATURE_UNIT) + ) is not None and unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_WIND_SPEED_UNIT) + ) is None or unit in ( + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, # 1km/h = 0.62mi/s + UnitOfSpeed.FEET_PER_SECOND, # 1km/h = 0.91ft/s + ): + absolute_change = 1.0 + elif unit == UnitOfSpeed.METERS_PER_SECOND: # 1km/h = 0.277m/s + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_CLOUD_COVERAGE, # range 0-100% + ATTR_WEATHER_HUMIDITY, # range 0-100% + ATTR_WEATHER_OZONE, # range ~20-100ppm + ATTR_WEATHER_VISIBILITY, # range 0-240km (150mi) + ATTR_WEATHER_WIND_BEARING, # range 0-359° + ): + absolute_change = 1.0 + + if attr_name == ATTR_WEATHER_UV_INDEX: # range 1-11 + absolute_change = 0.1 + + if attr_name == ATTR_WEATHER_PRESSURE: # local variation of around 100 hpa + if (unit := new_attrs.get(ATTR_WEATHER_PRESSURE_UNIT)) is None or unit in ( + UnitOfPressure.HPA, + UnitOfPressure.MBAR, # 1hPa = 1mbar + UnitOfPressure.MMHG, # 1hPa = 0.75mmHg + ): + absolute_change = 1.0 + elif unit == UnitOfPressure.INHG: # 1hPa = 0.03inHg + absolute_change = 0.05 + + # check for significant attribute value change + if absolute_change is not None: + if check_absolute_change(old_attr_value, new_attr_value, absolute_change): + return True + + # no significant attribute change detected + return False diff --git a/tests/components/weather/test_significant_change.py b/tests/components/weather/test_significant_change.py new file mode 100644 index 00000000000..93e5830a0ac --- /dev/null +++ b/tests/components/weather/test_significant_change.py @@ -0,0 +1,347 @@ +"""Test the Weather significant change platform.""" + +import pytest + +from homeassistant.components.weather.const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY_UNIT, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) +from homeassistant.components.weather.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature + + +async def test_significant_state_change() -> None: + """Detect Weather significant state changes.""" + assert not async_check_significant_change( + None, "clear-night", {}, "clear-night", {} + ) + assert async_check_significant_change(None, "clear-night", {}, "cloudy", {}) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # insignificant attributes + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b"}, + False, + ), + ({ATTR_WEATHER_PRESSURE_UNIT: "a"}, {ATTR_WEATHER_PRESSURE_UNIT: "b"}, False), + ( + {ATTR_WEATHER_TEMPERATURE_UNIT: "a"}, + {ATTR_WEATHER_TEMPERATURE_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_VISIBILITY_UNIT: "a"}, + {ATTR_WEATHER_VISIBILITY_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_WIND_SPEED_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + False, + ), + # significant attributes, close to but not significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 68.9, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + False, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.4}, + False, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 80.9}, + False, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "W"}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.09}, + False, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 20.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1000.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 750.06}, + { + ATTR_WEATHER_PRESSURE: 750.74, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.54, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + False, + ), + # significant attributes with significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 69, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + True, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.5}, + True, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 81}, + True, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "NW"}, # NW = 315° + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.1}, + True, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 21}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1001}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 750}, + { + ATTR_WEATHER_PRESSURE: 749, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.55, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + True, + ), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # invalid new values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + False, + ), + # invalid old values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + True, + ), + ], +) +async def test_invalid_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather invalid attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) From 94d168e20e3bf1b18b6d163a3b2df953c4c95a55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 08:53:29 -1000 Subject: [PATCH 510/643] Move Bluetooth advertisement tracker to habluetooth library (#105083) --- .../bluetooth/advertisement_tracker.py | 82 ------------------- homeassistant/components/bluetooth/manager.py | 5 +- .../bluetooth/test_advertisement_tracker.py | 4 +- .../components/bluetooth/test_base_scanner.py | 4 +- .../private_ble_device/test_device_tracker.py | 5 +- .../private_ble_device/test_sensor.py | 5 +- 6 files changed, 7 insertions(+), 98 deletions(-) delete mode 100644 homeassistant/components/bluetooth/advertisement_tracker.py diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py deleted file mode 100644 index f17bcf938f5..00000000000 --- a/homeassistant/components/bluetooth/advertisement_tracker.py +++ /dev/null @@ -1,82 +0,0 @@ -"""The bluetooth integration advertisement tracker.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.core import callback - -from .models import BluetoothServiceInfoBleak - -ADVERTISING_TIMES_NEEDED = 16 - -# Each scanner may buffer incoming packets so -# we need to give a bit of leeway before we -# mark a device unavailable -TRACKER_BUFFERING_WOBBLE_SECONDS = 5 - - -class AdvertisementTracker: - """Tracker to determine the interval that a device is advertising.""" - - __slots__ = ("intervals", "fallback_intervals", "sources", "_timings") - - def __init__(self) -> None: - """Initialize the tracker.""" - self.intervals: dict[str, float] = {} - self.fallback_intervals: dict[str, float] = {} - self.sources: dict[str, str] = {} - self._timings: dict[str, list[float]] = {} - - @callback - def async_diagnostics(self) -> dict[str, dict[str, Any]]: - """Return diagnostics.""" - return { - "intervals": self.intervals, - "fallback_intervals": self.fallback_intervals, - "sources": self.sources, - "timings": self._timings, - } - - @callback - def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None: - """Collect timings for the tracker. - - For performance reasons, it is the responsibility of the - caller to check if the device already has an interval set or - the source has changed before calling this function. - """ - address = service_info.address - self.sources[address] = service_info.source - timings = self._timings.setdefault(address, []) - timings.append(service_info.time) - if len(timings) != ADVERTISING_TIMES_NEEDED: - return - - max_time_between_advertisements = timings[1] - timings[0] - for i in range(2, len(timings)): - time_between_advertisements = timings[i] - timings[i - 1] - if time_between_advertisements > max_time_between_advertisements: - max_time_between_advertisements = time_between_advertisements - - # We now know the maximum time between advertisements - self.intervals[address] = max_time_between_advertisements - del self._timings[address] - - @callback - def async_remove_address(self, address: str) -> None: - """Remove the tracker.""" - self.intervals.pop(address, None) - self.sources.pop(address, None) - self._timings.pop(address, None) - - @callback - def async_remove_fallback_interval(self, address: str) -> None: - """Remove fallback interval.""" - self.fallback_intervals.pop(address, None) - - @callback - def async_remove_source(self, source: str) -> None: - """Remove the tracker.""" - for address, tracked_source in list(self.sources.items()): - if tracked_source == source: - self.async_remove_address(address) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index e9f490285c9..9c3517982af 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -17,6 +17,7 @@ from bluetooth_adapters import ( BluetoothAdapters, ) from bluetooth_data_tools import monotonic_time_coarse +from habluetooth import TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker from homeassistant import config_entries from homeassistant.const import EVENT_LOGGING_CHANGED @@ -29,10 +30,6 @@ from homeassistant.core import ( from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_track_time_interval -from .advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, - AdvertisementTracker, -) from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index 6ae847ba84a..8681287baa2 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -3,6 +3,7 @@ from datetime import timedelta import time from unittest.mock import patch +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED import pytest from homeassistant.components.bluetooth import ( @@ -10,9 +11,6 @@ from homeassistant.components.bluetooth import ( async_register_scanner, async_track_unavailable, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.components.bluetooth.const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SOURCE_LOCAL, diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 5886cc10aac..1228a4efc5b 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -8,6 +8,7 @@ from unittest.mock import patch from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth @@ -17,9 +18,6 @@ from homeassistant.components.bluetooth import ( HomeAssistantRemoteScanner, storage, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, -) from homeassistant.components.bluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py index d8b30738865..3834254ac7f 100644 --- a/tests/components/private_ble_device/test_device_tracker.py +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -3,9 +3,8 @@ import time -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth.api import ( async_get_fallback_availability_interval, ) diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py index 65f08d5653d..e35643d7626 100644 --- a/tests/components/private_ble_device/test_sensor.py +++ b/tests/components/private_ble_device/test_sensor.py @@ -1,10 +1,9 @@ """Tests for sensors.""" +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth import async_set_fallback_availability_interval -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.core import HomeAssistant from . import ( From 3c635fdbf2db3ce65737aa45406d2ad573ab9001 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 08:54:50 -1000 Subject: [PATCH 511/643] Split bluetooth manager so it can be extracted into the habluetooth lib (#105015) --- .../components/bluetooth/__init__.py | 10 +- homeassistant/components/bluetooth/api.py | 6 +- homeassistant/components/bluetooth/manager.py | 348 ++++++++++-------- .../components/bluetooth/wrappers.py | 1 - 4 files changed, 195 insertions(+), 170 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 99bb02054e7..329b597d515 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -81,7 +81,7 @@ from .const import ( LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, ) -from .manager import MONOTONIC_TIME, BluetoothManager +from .manager import MONOTONIC_TIME, HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher from .models import BluetoothCallback, BluetoothChange from .storage import BluetoothStorage @@ -143,11 +143,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await bluetooth_storage.async_setup() slot_manager = BleakSlotManager() await slot_manager.async_setup() - manager = BluetoothManager( + manager = HomeAssistantBluetoothManager( hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager ) await manager.async_setup() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda event: manager.async_stop() + ) hass.data[DATA_MANAGER] = models.MANAGER = manager adapters = await manager.async_get_bluetooth_adapters() @@ -284,7 +286,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: passive = entry.options.get(CONF_PASSIVE) mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE new_info_callback = async_get_advertisement_callback(hass) - manager: BluetoothManager = hass.data[DATA_MANAGER] + manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] scanner = HaScanner(mode, adapter, address, new_info_callback) try: scanner.async_setup() diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 897402d4049..afdd26a2001 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_ca from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import DATA_MANAGER -from .manager import BluetoothManager +from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback from .wrappers import HaBleakScannerWrapper @@ -25,9 +25,9 @@ if TYPE_CHECKING: from bleak.backends.device import BLEDevice -def _get_manager(hass: HomeAssistant) -> BluetoothManager: +def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager: """Get the bluetooth manager.""" - return cast(BluetoothManager, hass.data[DATA_MANAGER]) + return cast(HomeAssistantBluetoothManager, hass.data[DATA_MANAGER]) @hass_callback diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 9c3517982af..777d0ebe317 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable -from datetime import datetime, timedelta import itertools import logging from typing import TYPE_CHECKING, Any, Final @@ -28,7 +27,6 @@ from homeassistant.core import ( callback as hass_callback, ) from homeassistant.helpers import discovery_flow -from homeassistant.helpers.event import async_track_time_interval from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import ( @@ -100,16 +98,12 @@ class BluetoothManager: """Manage Bluetooth.""" __slots__ = ( - "hass", - "_integration_matcher", "_cancel_unavailable_tracking", - "_cancel_logging_listener", "_advertisement_tracker", "_fallback_intervals", "_intervals", "_unavailable_callbacks", "_connectable_unavailable_callbacks", - "_callback_index", "_bleak_callbacks", "_all_history", "_connectable_history", @@ -122,21 +116,17 @@ class BluetoothManager: "slot_manager", "_debug", "shutdown", + "_loop", ) def __init__( self, - hass: HomeAssistant, - integration_matcher: IntegrationMatcher, bluetooth_adapters: BluetoothAdapters, storage: BluetoothStorage, slot_manager: BleakSlotManager, ) -> None: """Init bluetooth manager.""" - self.hass = hass - self._integration_matcher = integration_matcher - self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None - self._cancel_logging_listener: CALLBACK_TYPE | None = None + self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None self._advertisement_tracker = AdvertisementTracker() self._fallback_intervals = self._advertisement_tracker.fallback_intervals @@ -149,7 +139,6 @@ class BluetoothManager: str, list[Callable[[BluetoothServiceInfoBleak], None]] ] = {} - self._callback_index = BluetoothCallbackMatcherIndex() self._bleak_callbacks: list[ tuple[AdvertisementDataCallback, dict[str, set[str]]] ] = [] @@ -164,6 +153,7 @@ class BluetoothManager: self.slot_manager = slot_manager self._debug = _LOGGER.isEnabledFor(logging.DEBUG) self.shutdown = False + self._loop: asyncio.AbstractEventLoop | None = None @property def supports_passive_scan(self) -> bool: @@ -206,7 +196,6 @@ class BluetoothManager: return adapter return None - @hass_callback def async_scanner_by_source(self, source: str) -> BaseHaScanner | None: """Return the scanner for a source.""" return self._sources.get(source) @@ -229,45 +218,22 @@ class BluetoothManager: self._adapters = self._bluetooth_adapters.adapters return self._find_adapter_by_address(address) - @hass_callback - def _async_logging_changed(self, event: Event) -> None: - """Handle logging change.""" - self._debug = _LOGGER.isEnabledFor(logging.DEBUG) - async def async_setup(self) -> None: """Set up the bluetooth manager.""" + self._loop = asyncio.get_running_loop() await self._bluetooth_adapters.refresh() install_multiple_bleak_catcher() - self._all_history, self._connectable_history = async_load_history_from_system( - self._bluetooth_adapters, self.storage - ) - self._cancel_logging_listener = self.hass.bus.async_listen( - EVENT_LOGGING_CHANGED, self._async_logging_changed - ) self.async_setup_unavailable_tracking() - seen: set[str] = set() - for address, service_info in itertools.chain( - self._connectable_history.items(), self._all_history.items() - ): - if address in seen: - continue - seen.add(address) - self._async_trigger_matching_discovery(service_info) - @hass_callback - def async_stop(self, event: Event) -> None: + def async_stop(self) -> None: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") self.shutdown = True if self._cancel_unavailable_tracking: - self._cancel_unavailable_tracking() + self._cancel_unavailable_tracking.cancel() self._cancel_unavailable_tracking = None - if self._cancel_logging_listener: - self._cancel_logging_listener() - self._cancel_logging_listener = None uninstall_multiple_bleak_catcher() - @hass_callback def async_scanner_devices_by_address( self, address: str, connectable: bool ) -> list[BluetoothScannerDevice]: @@ -288,7 +254,6 @@ class BluetoothManager: ) ] - @hass_callback def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: """Return all of discovered addresses. @@ -304,24 +269,25 @@ class BluetoothManager: for scanner in self._non_connectable_scanners ) - @hass_callback def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: """Return all of combined best path to discovered from all the scanners.""" histories = self._connectable_history if connectable else self._all_history return [history.device for history in histories.values()] - @hass_callback def async_setup_unavailable_tracking(self) -> None: """Set up the unavailable tracking.""" - self._cancel_unavailable_tracking = async_track_time_interval( - self.hass, - self._async_check_unavailable, - timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), - name="Bluetooth manager unavailable tracking", + self._schedule_unavailable_tracking() + + def _schedule_unavailable_tracking(self) -> None: + """Schedule the unavailable tracking.""" + if TYPE_CHECKING: + assert self._loop is not None + loop = self._loop + self._cancel_unavailable_tracking = loop.call_at( + loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable ) - @hass_callback - def _async_check_unavailable(self, now: datetime) -> None: + def _async_check_unavailable(self) -> None: """Watch for unavailable devices and cleanup state history.""" monotonic_now = MONOTONIC_TIME() connectable_history = self._connectable_history @@ -363,8 +329,7 @@ class BluetoothManager: # available for both connectable and non-connectable tracker.async_remove_fallback_interval(address) tracker.async_remove_address(address) - self._integration_matcher.async_clear_address(address) - self._async_dismiss_discoveries(address) + self._address_disappeared(address) service_info = history.pop(address) @@ -377,13 +342,13 @@ class BluetoothManager: except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in unavailable callback") - def _async_dismiss_discoveries(self, address: str) -> None: - """Dismiss all discoveries for the given address.""" - for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - BluetoothServiceInfoBleak, - lambda service_info: bool(service_info.address == address), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) + self._schedule_unavailable_tracking() + + def _address_disappeared(self, address: str) -> None: + """Call when an address disappears from the stack. + + This method is intended to be overridden by subclasses. + """ def _prefer_previous_adv_from_different_source( self, @@ -436,7 +401,6 @@ class BluetoothManager: return False return True - @hass_callback def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: """Handle a new advertisement from any scanner. @@ -567,16 +531,6 @@ class BluetoothManager: time=service_info.time, ) - matched_domains = self._integration_matcher.match_domains(service_info) - if self._debug: - _LOGGER.debug( - "%s: %s %s match: %s", - self._async_describe_source(service_info), - address, - service_info.advertisement, - matched_domains, - ) - if (connectable or old_connectable_service_info) and ( bleak_callbacks := self._bleak_callbacks ): @@ -586,22 +540,14 @@ class BluetoothManager: for callback_filters in bleak_callbacks: _dispatch_bleak_callback(*callback_filters, device, advertisement_data) - for match in self._callback_index.match_callbacks(service_info): - callback = match[CALLBACK] - try: - callback(service_info, BluetoothChange.ADVERTISEMENT) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in bluetooth callback") + self._discover_service_info(service_info) - for domain in matched_domains: - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, - ) + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: + """Discover a new service info. + + This method is intended to be overridden by subclasses. + """ - @hass_callback def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str: """Describe a source.""" if scanner := self._sources.get(service_info.source): @@ -612,7 +558,6 @@ class BluetoothManager: description += " [connectable]" return description - @hass_callback def async_track_unavailable( self, callback: Callable[[BluetoothServiceInfoBleak], None], @@ -626,7 +571,6 @@ class BluetoothManager: unavailable_callbacks = self._unavailable_callbacks unavailable_callbacks.setdefault(address, []).append(callback) - @hass_callback def _async_remove_callback() -> None: unavailable_callbacks[address].remove(callback) if not unavailable_callbacks[address]: @@ -634,50 +578,6 @@ class BluetoothManager: return _async_remove_callback - @hass_callback - def async_register_callback( - self, - callback: BluetoothCallback, - matcher: BluetoothCallbackMatcher | None, - ) -> Callable[[], None]: - """Register a callback.""" - callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback) - if not matcher: - callback_matcher[CONNECTABLE] = True - else: - # We could write out every item in the typed dict here - # but that would be a bit inefficient and verbose. - callback_matcher.update(matcher) - callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True) - - connectable = callback_matcher[CONNECTABLE] - self._callback_index.add_callback_matcher(callback_matcher) - - @hass_callback - def _async_remove_callback() -> None: - self._callback_index.remove_callback_matcher(callback_matcher) - - # If we have history for the subscriber, we can trigger the callback - # immediately with the last packet so the subscriber can see the - # device. - history = self._connectable_history if connectable else self._all_history - service_infos: Iterable[BluetoothServiceInfoBleak] = [] - if address := callback_matcher.get(ADDRESS): - if service_info := history.get(address): - service_infos = [service_info] - else: - service_infos = history.values() - - for service_info in service_infos: - if ble_device_matches(callback_matcher, service_info): - try: - callback(service_info, BluetoothChange.ADVERTISEMENT) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in bluetooth callback") - - return _async_remove_callback - - @hass_callback def async_ble_device_from_address( self, address: str, connectable: bool ) -> BLEDevice | None: @@ -687,13 +587,11 @@ class BluetoothManager: return history.device return None - @hass_callback def async_address_present(self, address: str, connectable: bool) -> bool: """Return if the address is present.""" histories = self._connectable_history if connectable else self._all_history return address in histories - @hass_callback def async_discovered_service_info( self, connectable: bool ) -> Iterable[BluetoothServiceInfoBleak]: @@ -701,7 +599,6 @@ class BluetoothManager: histories = self._connectable_history if connectable else self._all_history return histories.values() - @hass_callback def async_last_service_info( self, address: str, connectable: bool ) -> BluetoothServiceInfoBleak | None: @@ -709,28 +606,6 @@ class BluetoothManager: histories = self._connectable_history if connectable else self._all_history return histories.get(address) - def _async_trigger_matching_discovery( - self, service_info: BluetoothServiceInfoBleak - ) -> None: - """Trigger discovery for matching domains.""" - for domain in self._integration_matcher.match_domains(service_info): - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, - ) - - @hass_callback - def async_rediscover_address(self, address: str) -> None: - """Trigger discovery of devices which have already been seen.""" - self._integration_matcher.async_clear_address(address) - if service_info := self._connectable_history.get(address): - self._async_trigger_matching_discovery(service_info) - return - if service_info := self._all_history.get(address): - self._async_trigger_matching_discovery(service_info) - def async_register_scanner( self, scanner: BaseHaScanner, @@ -758,7 +633,6 @@ class BluetoothManager: self.slot_manager.register_adapter(scanner.adapter, connection_slots) return _unregister_scanner - @hass_callback def async_register_bleak_callback( self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] ) -> CALLBACK_TYPE: @@ -766,7 +640,6 @@ class BluetoothManager: callback_entry = (callback, filters) self._bleak_callbacks.append(callback_entry) - @hass_callback def _remove_callback() -> None: self._bleak_callbacks.remove(callback_entry) @@ -780,29 +653,180 @@ class BluetoothManager: return _remove_callback - @hass_callback def async_release_connection_slot(self, device: BLEDevice) -> None: """Release a connection slot.""" self.slot_manager.release_slot(device) - @hass_callback def async_allocate_connection_slot(self, device: BLEDevice) -> bool: """Allocate a connection slot.""" return self.slot_manager.allocate_slot(device) - @hass_callback def async_get_learned_advertising_interval(self, address: str) -> float | None: """Get the learned advertising interval for a MAC address.""" return self._intervals.get(address) - @hass_callback def async_get_fallback_availability_interval(self, address: str) -> float | None: """Get the fallback availability timeout for a MAC address.""" return self._fallback_intervals.get(address) - @hass_callback def async_set_fallback_availability_interval( self, address: str, interval: float ) -> None: """Override the fallback availability timeout for a MAC address.""" self._fallback_intervals[address] = interval + + +class HomeAssistantBluetoothManager(BluetoothManager): + """Manage Bluetooth for Home Assistant.""" + + __slots__ = ( + "hass", + "_integration_matcher", + "_callback_index", + "_cancel_logging_listener", + ) + + def __init__( + self, + hass: HomeAssistant, + integration_matcher: IntegrationMatcher, + bluetooth_adapters: BluetoothAdapters, + storage: BluetoothStorage, + slot_manager: BleakSlotManager, + ) -> None: + """Init bluetooth manager.""" + self.hass = hass + self._integration_matcher = integration_matcher + self._callback_index = BluetoothCallbackMatcherIndex() + self._cancel_logging_listener: CALLBACK_TYPE | None = None + super().__init__(bluetooth_adapters, storage, slot_manager) + + @hass_callback + def _async_logging_changed(self, event: Event) -> None: + """Handle logging change.""" + self._debug = _LOGGER.isEnabledFor(logging.DEBUG) + + def _async_trigger_matching_discovery( + self, service_info: BluetoothServiceInfoBleak + ) -> None: + """Trigger discovery for matching domains.""" + for domain in self._integration_matcher.match_domains(service_info): + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) + + @hass_callback + def async_rediscover_address(self, address: str) -> None: + """Trigger discovery of devices which have already been seen.""" + self._integration_matcher.async_clear_address(address) + if service_info := self._connectable_history.get(address): + self._async_trigger_matching_discovery(service_info) + return + if service_info := self._all_history.get(address): + self._async_trigger_matching_discovery(service_info) + + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: + matched_domains = self._integration_matcher.match_domains(service_info) + if self._debug: + _LOGGER.debug( + "%s: %s %s match: %s", + self._async_describe_source(service_info), + service_info.address, + service_info.advertisement, + matched_domains, + ) + + for match in self._callback_index.match_callbacks(service_info): + callback = match[CALLBACK] + try: + callback(service_info, BluetoothChange.ADVERTISEMENT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in bluetooth callback") + + for domain in matched_domains: + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) + + def _address_disappeared(self, address: str) -> None: + """Dismiss all discoveries for the given address.""" + self._integration_matcher.async_clear_address(address) + for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( + BluetoothServiceInfoBleak, + lambda service_info: bool(service_info.address == address), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + + async def async_setup(self) -> None: + """Set up the bluetooth manager.""" + await super().async_setup() + self._all_history, self._connectable_history = async_load_history_from_system( + self._bluetooth_adapters, self.storage + ) + self._cancel_logging_listener = self.hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) + seen: set[str] = set() + for address, service_info in itertools.chain( + self._connectable_history.items(), self._all_history.items() + ): + if address in seen: + continue + seen.add(address) + self._async_trigger_matching_discovery(service_info) + + def async_register_callback( + self, + callback: BluetoothCallback, + matcher: BluetoothCallbackMatcher | None, + ) -> Callable[[], None]: + """Register a callback.""" + callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback) + if not matcher: + callback_matcher[CONNECTABLE] = True + else: + # We could write out every item in the typed dict here + # but that would be a bit inefficient and verbose. + callback_matcher.update(matcher) + callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True) + + connectable = callback_matcher[CONNECTABLE] + self._callback_index.add_callback_matcher(callback_matcher) + + def _async_remove_callback() -> None: + self._callback_index.remove_callback_matcher(callback_matcher) + + # If we have history for the subscriber, we can trigger the callback + # immediately with the last packet so the subscriber can see the + # device. + history = self._connectable_history if connectable else self._all_history + service_infos: Iterable[BluetoothServiceInfoBleak] = [] + if address := callback_matcher.get(ADDRESS): + if service_info := history.get(address): + service_infos = [service_info] + else: + service_infos = history.values() + + for service_info in service_infos: + if ble_device_matches(callback_matcher, service_info): + try: + callback(service_info, BluetoothChange.ADVERTISEMENT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in bluetooth callback") + + return _async_remove_callback + + @hass_callback + def async_stop(self) -> None: + """Stop the Bluetooth integration at shutdown.""" + _LOGGER.debug("Stopping bluetooth manager") + super().async_stop() + if self._cancel_logging_listener: + self._cancel_logging_listener() + self._cancel_logging_listener = None diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 9de020f163e..e3c08a035a8 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -283,7 +283,6 @@ class HaBleakClientWrapper(BleakClient): self.__disconnected_callback ), timeout=self.__timeout, - hass=manager.hass, ) if debug_logging: # Only lookup the description if we are going to log it From be44de0a416f28554fe2be725f66cfe3c9d5d638 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 5 Dec 2023 20:00:53 +0100 Subject: [PATCH 512/643] Fix overkiz measurement sensor returns None if 0 (#105090) --- homeassistant/components/overkiz/sensor.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 0bb9043c040..a267b54b398 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -481,7 +481,12 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): """Return the value of the sensor.""" state = self.device.states.get(self.entity_description.key) - if not state or not state.value: + if ( + state is None + or state.value is None + or self.state_class != SensorStateClass.MEASUREMENT + and not state.value + ): return None # Transform the value with a lambda function From eadcceeed15c420c03eca6ae25760d1046ea0012 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 5 Dec 2023 11:45:00 -0800 Subject: [PATCH 513/643] Update apple_weatherkit to 1.1.1 (#105079) --- homeassistant/components/weatherkit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index d28a6ff3315..a2ddde02ad4 100644 --- a/homeassistant/components/weatherkit/manifest.json +++ b/homeassistant/components/weatherkit/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherkit", "iot_class": "cloud_polling", - "requirements": ["apple_weatherkit==1.0.4"] + "requirements": ["apple_weatherkit==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 45be2b74637..50d275f5dc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -437,7 +437,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.1 # homeassistant.components.apprise apprise==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d0e94eaaee..45822622d4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.1 # homeassistant.components.apprise apprise==1.6.0 From 19e193ae1dd8197e1c6775b70132b3e9fc9760b4 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 5 Dec 2023 11:45:26 -0800 Subject: [PATCH 514/643] Increase frequency of weatherkit updates (#105094) --- homeassistant/components/weatherkit/coordinator.py | 2 +- tests/components/weatherkit/test_coordinator.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index a918ce0f850..824c85781ea 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -37,7 +37,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(minutes=15), + update_interval=timedelta(minutes=5), ) async def update_supported_data_sets(self): diff --git a/tests/components/weatherkit/test_coordinator.py b/tests/components/weatherkit/test_coordinator.py index f619ace237a..7113e1d4d51 100644 --- a/tests/components/weatherkit/test_coordinator.py +++ b/tests/components/weatherkit/test_coordinator.py @@ -23,7 +23,7 @@ async def test_failed_updates(hass: HomeAssistant) -> None: ): async_fire_time_changed( hass, - utcnow() + timedelta(minutes=15), + utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() From 712a401ee23b0b4306fe79da16b0e44dafcf8fb5 Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Tue, 5 Dec 2023 21:06:29 +0100 Subject: [PATCH 515/643] Bump renson library to version 1.7.1 (#105096) --- homeassistant/components/renson/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json index 1a7f367a946..fa94207748e 100644 --- a/homeassistant/components/renson/manifest.json +++ b/homeassistant/components/renson/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/renson", "iot_class": "local_polling", - "requirements": ["renson-endura-delta==1.6.0"] + "requirements": ["renson-endura-delta==1.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 50d275f5dc1..337764a1ddf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2345,7 +2345,7 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink reolink-aio==0.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45822622d4e..2096184ed73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1757,7 +1757,7 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink reolink-aio==0.8.1 From 44810f9772eb89bf171022f42beeb17bea2408e4 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 22:16:07 +0100 Subject: [PATCH 516/643] Bump aiounifi to v67 (#105099) * Bump aiounifi to v67 * Fix mypy --- homeassistant/components/unifi/controller.py | 4 ++-- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 6bd8b9db426..035cf66a983 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -5,7 +5,7 @@ import asyncio from datetime import datetime, timedelta import ssl from types import MappingProxyType -from typing import Any +from typing import Any, Literal from aiohttp import CookieJar import aiounifi @@ -458,7 +458,7 @@ async def get_unifi_controller( config: MappingProxyType[str, Any], ) -> aiounifi.Controller: """Create a controller object and verify authentication.""" - ssl_context: ssl.SSLContext | bool = False + ssl_context: ssl.SSLContext | Literal[False] = False if verify_ssl := config.get(CONF_VERIFY_SSL): session = aiohttp_client.async_get_clientsession(hass) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 52ed8ec3101..7d4717d3fff 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==66"], + "requirements": ["aiounifi==67"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 337764a1ddf..06db4496d2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -374,7 +374,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==66 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2096184ed73..9c542e5718e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==66 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 636e38f4b390727ee2ca4c49c7106d0b5e132b7d Mon Sep 17 00:00:00 2001 From: Tudor Sandu Date: Tue, 5 Dec 2023 13:24:41 -0800 Subject: [PATCH 517/643] Trigger Home Assistant shutdown automations right before the stop event instead of during it (#91165) Co-authored-by: Erik --- Dockerfile | 2 +- .../homeassistant/triggers/homeassistant.py | 33 +++--- homeassistant/core.py | 108 ++++++++++++++---- script/hassfest/docker.py | 7 +- tests/test_core.py | 54 +++++++++ tests/test_runner.py | 9 +- 6 files changed, 164 insertions(+), 49 deletions(-) diff --git a/Dockerfile b/Dockerfile index 97eeb5b0dfa..43b21ab3ba8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM ${BUILD_FROM} # Synchronize with homeassistant/core.py:async_stop ENV \ - S6_SERVICES_GRACETIME=220000 + S6_SERVICES_GRACETIME=240000 ARG QEMU_CPU diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index 51686e54c55..84aafb44808 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -1,8 +1,8 @@ """Offer Home Assistant core automation rules.""" import voluptuous as vol -from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.const import CONF_EVENT, CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -30,24 +30,17 @@ async def async_attach_trigger( job = HassJob(action, f"homeassistant trigger {trigger_info}") if event == EVENT_SHUTDOWN: - - @callback - def hass_shutdown(event): - """Execute when Home Assistant is shutting down.""" - hass.async_run_hass_job( - job, - { - "trigger": { - **trigger_data, - "platform": "homeassistant", - "event": event, - "description": "Home Assistant stopping", - } - }, - event.context, - ) - - return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_shutdown) + return hass.async_add_shutdown_job( + job, + { + "trigger": { + **trigger_data, + "platform": "homeassistant", + "event": event, + "description": "Home Assistant stopping", + } + }, + ) # Automation are enabled while hass is starting up, fire right away # Check state because a config reload shouldn't trigger it. diff --git a/homeassistant/core.py b/homeassistant/core.py index 7d9d8d19b49..7f0883ca880 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -18,6 +18,7 @@ from collections.abc import ( ) import concurrent.futures from contextlib import suppress +from dataclasses import dataclass import datetime import enum import functools @@ -107,9 +108,10 @@ if TYPE_CHECKING: from .helpers.entity import StateInfo -STAGE_1_SHUTDOWN_TIMEOUT = 100 -STAGE_2_SHUTDOWN_TIMEOUT = 60 -STAGE_3_SHUTDOWN_TIMEOUT = 30 +STOPPING_STAGE_SHUTDOWN_TIMEOUT = 20 +STOP_STAGE_SHUTDOWN_TIMEOUT = 100 +FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT = 60 +CLOSE_STAGE_SHUTDOWN_TIMEOUT = 30 block_async_io.enable() @@ -299,6 +301,14 @@ class HassJob(Generic[_P, _R_co]): return f"" +@dataclass(frozen=True) +class HassJobWithArgs: + """Container for a HassJob and arguments.""" + + job: HassJob[..., Coroutine[Any, Any, Any] | Any] + args: Iterable[Any] + + def _get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: """Determine the job type from the callable.""" # Check for partials to properly determine if coroutine function @@ -370,6 +380,7 @@ class HomeAssistant: # Timeout handler for Core/Helper namespace self.timeout: TimeoutManager = TimeoutManager() self._stop_future: concurrent.futures.Future[None] | None = None + self._shutdown_jobs: list[HassJobWithArgs] = [] @property def is_running(self) -> bool: @@ -766,6 +777,42 @@ class HomeAssistant: for task in pending: _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any]], *args: Any + ) -> CALLBACK_TYPE: + ... + + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + ... + + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + """Add a HassJob which will be executed on shutdown. + + This method must be run in the event loop. + + hassjob: HassJob + args: parameters for method to call. + + Returns function to remove the job. + """ + job_with_args = HassJobWithArgs(hassjob, args) + self._shutdown_jobs.append(job_with_args) + + @callback + def remove_job() -> None: + self._shutdown_jobs.remove(job_with_args) + + return remove_job + def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" if self.state == CoreState.not_running: # just ignore @@ -799,6 +846,26 @@ class HomeAssistant: "Stopping Home Assistant before startup has completed may fail" ) + # Stage 1 - Run shutdown jobs + try: + async with self.timeout.async_timeout(STOPPING_STAGE_SHUTDOWN_TIMEOUT): + tasks: list[asyncio.Future[Any]] = [] + for job in self._shutdown_jobs: + task_or_none = self.async_run_hass_job(job.job, *job.args) + if not task_or_none: + continue + tasks.append(task_or_none) + if tasks: + asyncio.gather(*tasks, return_exceptions=True) + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown jobs to complete, the shutdown will" + " continue" + ) + self._async_log_running_tasks("run shutdown jobs") + + # Stage 2 - Stop integrations + # Keep holding the reference to the tasks but do not allow them # to block shutdown. Only tasks created after this point will # be waited for. @@ -816,33 +883,32 @@ class HomeAssistant: self.exit_code = exit_code - # stage 1 self.state = CoreState.stopping self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) try: - async with self.timeout.async_timeout(STAGE_1_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 1 to complete, the shutdown will" + "Timed out waiting for integrations to stop, the shutdown will" " continue" ) - self._async_log_running_tasks(1) + self._async_log_running_tasks("stop integrations") - # stage 2 + # Stage 3 - Final write self.state = CoreState.final_write self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) try: - async with self.timeout.async_timeout(STAGE_2_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 2 to complete, the shutdown will" + "Timed out waiting for final writes to complete, the shutdown will" " continue" ) - self._async_log_running_tasks(2) + self._async_log_running_tasks("final write") - # stage 3 + # Stage 4 - Close self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) @@ -856,12 +922,12 @@ class HomeAssistant: # were awaiting another task continue _LOGGER.warning( - "Task %s was still running after stage 2 shutdown; " + "Task %s was still running after final writes shutdown stage; " "Integrations should cancel non-critical tasks when receiving " "the stop event to prevent delaying shutdown", task, ) - task.cancel("Home Assistant stage 2 shutdown") + task.cancel("Home Assistant final writes shutdown stage") try: async with asyncio.timeout(0.1): await task @@ -870,11 +936,11 @@ class HomeAssistant: except asyncio.TimeoutError: # Task may be shielded from cancellation. _LOGGER.exception( - "Task %s could not be canceled during stage 3 shutdown", task + "Task %s could not be canceled during final shutdown stage", task ) except Exception as exc: # pylint: disable=broad-except _LOGGER.exception( - "Task %s error during stage 3 shutdown: %s", task, exc + "Task %s error during final shutdown stage: %s", task, exc ) # Prevent run_callback_threadsafe from scheduling any additional @@ -885,14 +951,14 @@ class HomeAssistant: shutdown_run_callback_threadsafe(self.loop) try: - async with self.timeout.async_timeout(STAGE_3_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(CLOSE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 3 to complete, the shutdown will" + "Timed out waiting for close event to be processed, the shutdown will" " continue" ) - self._async_log_running_tasks(3) + self._async_log_running_tasks("close") self.state = CoreState.stopped @@ -912,10 +978,10 @@ class HomeAssistant: ): handle.cancel() - def _async_log_running_tasks(self, stage: int) -> None: + def _async_log_running_tasks(self, stage: str) -> None: """Log all running tasks.""" for task in self._tasks: - _LOGGER.warning("Shutdown stage %s: still running: %s", stage, task) + _LOGGER.warning("Shutdown stage '%s': still running: %s", stage, task) class Context: diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 3bd44736038..c9d81424229 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -59,9 +59,10 @@ WORKDIR /config def _generate_dockerfile() -> str: timeout = ( - core.STAGE_1_SHUTDOWN_TIMEOUT - + core.STAGE_2_SHUTDOWN_TIMEOUT - + core.STAGE_3_SHUTDOWN_TIMEOUT + core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + 10 diff --git a/tests/test_core.py b/tests/test_core.py index 43291c032d7..d5b3ba5f87d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -36,6 +36,7 @@ from homeassistant.const import ( ) import homeassistant.core as ha from homeassistant.core import ( + CoreState, HassJob, HomeAssistant, ServiceCall, @@ -399,6 +400,32 @@ async def test_stage_shutdown(hass: HomeAssistant) -> None: assert len(test_all) == 2 +async def test_stage_shutdown_timeouts(hass: HomeAssistant) -> None: + """Simulate a shutdown, test timeouts at each step.""" + + with patch.object(hass.timeout, "async_timeout", side_effect=asyncio.TimeoutError): + await hass.async_stop() + + assert hass.state == CoreState.stopped + + +async def test_stage_shutdown_generic_error(hass: HomeAssistant, caplog) -> None: + """Simulate a shutdown, test that a generic error at the final stage doesn't prevent it.""" + + task = asyncio.Future() + hass._tasks.add(task) + + def fail_the_task(_): + task.set_exception(Exception("test_exception")) + + with patch.object(task, "cancel", side_effect=fail_the_task) as patched_call: + await hass.async_stop() + assert patched_call.called + + assert "test_exception" in caplog.text + assert hass.state == ha.CoreState.stopped + + async def test_stage_shutdown_with_exit_code(hass: HomeAssistant) -> None: """Simulate a shutdown, test calling stuff with exit code checks.""" test_stop = async_capture_events(hass, EVENT_HOMEASSISTANT_STOP) @@ -2566,3 +2593,30 @@ def test_hassjob_passing_job_type(): HassJob(not_callback_func, job_type=ha.HassJobType.Callback).job_type == ha.HassJobType.Callback ) + + +async def test_shutdown_job(hass: HomeAssistant) -> None: + """Test async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + hass.async_add_shutdown_job(job) + await hass.async_stop() + assert evt.is_set() + + +async def test_cancel_shutdown_job(hass: HomeAssistant) -> None: + """Test cancelling a job added to async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + cancel = hass.async_add_shutdown_job(job) + cancel() + await hass.async_stop() + assert not evt.is_set() diff --git a/tests/test_runner.py b/tests/test_runner.py index 3b06e3b64dc..14728321721 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util import executor, thread # https://github.com/home-assistant/supervisor/blob/main/supervisor/docker/homeassistant.py -SUPERVISOR_HARD_TIMEOUT = 220 +SUPERVISOR_HARD_TIMEOUT = 240 TIMEOUT_SAFETY_MARGIN = 10 @@ -21,9 +21,10 @@ TIMEOUT_SAFETY_MARGIN = 10 async def test_cumulative_shutdown_timeout_less_than_supervisor() -> None: """Verify the cumulative shutdown timeout is at least 10s less than the supervisor.""" assert ( - core.STAGE_1_SHUTDOWN_TIMEOUT - + core.STAGE_2_SHUTDOWN_TIMEOUT - + core.STAGE_3_SHUTDOWN_TIMEOUT + core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + TIMEOUT_SAFETY_MARGIN From ad26af608b5e7147844c9a9815cf3bfb8e042eb4 Mon Sep 17 00:00:00 2001 From: lunmay <28674102+lunmay@users.noreply.github.com> Date: Tue, 5 Dec 2023 22:25:08 +0100 Subject: [PATCH 518/643] Fix typo in v2c strings.json (#105104) fo -> of --- homeassistant/components/v2c/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index dafdd597e77..bf19fe5188e 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -6,7 +6,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address fo your V2C Trydan EVSE." + "host": "Hostname or IP address of your V2C Trydan EVSE." } } }, From 9a3b4939a9dbc4879d1b6fc025cd0c3ede9a1f18 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 6 Dec 2023 07:35:29 +0100 Subject: [PATCH 519/643] Update easyenergy lib to v2.0.0 (#105108) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6fa177c7221..0e57133a89a 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==1.0.0"] + "requirements": ["easyenergy==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 06db4496d2a..1f320b431a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -726,7 +726,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==1.0.0 +easyenergy==2.0.0 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c542e5718e..3872778c3f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -592,7 +592,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==1.0.0 +easyenergy==2.0.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 From 2401a09600e877fcc85d04eb1c8796532df741ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 21:13:35 -1000 Subject: [PATCH 520/643] Bump aioesphomeapi to 19.3.0 (#105114) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 7be54ad739f..e0b47f09d95 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==19.2.1", + "aioesphomeapi==19.3.0", "bluetooth-data-tools==1.17.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 1f320b431a0..f03ea9c458d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.1 +aioesphomeapi==19.3.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3872778c3f7..8b6533c5604 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.1 +aioesphomeapi==19.3.0 # homeassistant.components.flo aioflo==2021.11.0 From 3fda43ef334b6431fd94bb7a84f6da5d9a8e995a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 6 Dec 2023 01:14:34 -0600 Subject: [PATCH 521/643] Bump intents to 2023.12.05 (#105116) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2a069d5d92b..cb03499d8e4 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.29"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.12.05"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b36fe975184..c18c921a3d0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 home-assistant-frontend==20231205.0 -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index f03ea9c458d..5719561af7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1027,7 +1027,7 @@ holidays==0.37 home-assistant-frontend==20231205.0 # homeassistant.components.conversation -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b6533c5604..d164c1d5c47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -814,7 +814,7 @@ holidays==0.37 home-assistant-frontend==20231205.0 # homeassistant.components.conversation -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 From a5281835566ac2f6003c664aee46c6a2107b8107 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 21:15:24 -1000 Subject: [PATCH 522/643] Bump habluetooth to 0.8.0 (#105109) --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/__init__.py | 22 ++- .../components/bluetooth/test_base_scanner.py | 186 ++++-------------- tests/components/bluetooth/test_scanner.py | 81 ++++---- 7 files changed, 96 insertions(+), 201 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 24c1202a2fe..055eff43a91 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.20.0", - "habluetooth==0.6.1" + "habluetooth==0.8.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c18c921a3d0..10d041790a9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.20.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.6.1 +habluetooth==0.8.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index 5719561af7e..9fa892ca8c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.6.1 +habluetooth==0.8.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d164c1d5c47..c24e61c2204 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.6.1 +habluetooth==0.8.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 55d995dd63c..5261e7371f3 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -5,11 +5,12 @@ from contextlib import contextmanager import itertools import time from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import BaseHaScanner, BluetoothManager from homeassistant.components.bluetooth import ( DOMAIN, @@ -19,8 +20,6 @@ from homeassistant.components.bluetooth import ( async_get_advertisement_callback, models, ) -from homeassistant.components.bluetooth.base_scanner import BaseHaScanner -from homeassistant.components.bluetooth.manager import BluetoothManager from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -37,6 +36,7 @@ __all__ = ( "generate_advertisement_data", "generate_ble_device", "MockBleakClient", + "patch_bluetooth_time", ) ADVERTISEMENT_DATA_DEFAULTS = { @@ -56,6 +56,22 @@ BLE_DEVICE_DEFAULTS = { } +@contextmanager +def patch_bluetooth_time(mock_time: float) -> None: + """Patch the bluetooth time.""" + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=mock_time, + ), patch( + "homeassistant.components.bluetooth.MONOTONIC_TIME", return_value=mock_time + ), patch( + "habluetooth.base_scanner.monotonic_time_coarse", return_value=mock_time + ), patch( + "habluetooth.manager.monotonic_time_coarse", return_value=mock_time + ), patch("habluetooth.scanner.monotonic_time_coarse", return_value=mock_time): + yield + + def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: """Generate advertisement data with defaults.""" new = kwargs.copy() diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 1228a4efc5b..2e2be0e7963 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -35,11 +35,35 @@ from . import ( _get_manager, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed, load_fixture +class FakeScanner(HomeAssistantRemoteScanner): + """Fake scanner.""" + + def inject_advertisement( + self, + device: BLEDevice, + advertisement_data: AdvertisementData, + now: float | None = None, + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + now or MONOTONIC_TIME(), + ) + + @pytest.mark.parametrize("name_2", [None, "w"]) async def test_remote_scanner( hass: HomeAssistant, enable_bluetooth: None, name_2: str | None @@ -87,23 +111,6 @@ async def test_remote_scanner( rssi=-100, ) - class FakeScanner(HomeAssistantRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -171,23 +178,6 @@ async def test_remote_scanner_expires_connectable( rssi=-100, ) - class FakeScanner(HomeAssistantRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -212,10 +202,7 @@ async def test_remote_scanner_expires_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -246,23 +233,6 @@ async def test_remote_scanner_expires_non_connectable( rssi=-100, ) - class FakeScanner(HomeAssistantRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -295,10 +265,7 @@ async def test_remote_scanner_expires_non_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -311,10 +278,7 @@ async def test_remote_scanner_expires_non_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -344,23 +308,6 @@ async def test_base_scanner_connecting_behavior( rssi=-100, ) - class FakeScanner(HomeAssistantRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -468,23 +415,6 @@ async def test_device_with_ten_minute_advertising_interval( rssi=-100, ) - class FakeScanner(HomeAssistantRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -512,11 +442,8 @@ async def test_device_with_ten_minute_advertising_interval( connectable=False, ) - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement(bparasite_device, bparasite_device_adv, new_time) original_device = scanner.discovered_devices_and_advertisement_data[ bparasite_device.address @@ -525,11 +452,10 @@ async def test_device_with_ten_minute_advertising_interval( for _ in range(1, 20): new_time += advertising_interval - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, new_time + ) # Make sure the BLEDevice object gets updated # and not replaced @@ -543,10 +469,7 @@ async def test_device_with_ten_minute_advertising_interval( bluetooth.async_address_present(hass, bparasite_device.address, False) is True ) assert bparasite_device_went_unavailable is False - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=new_time, - ): + with patch_bluetooth_time(new_time): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=future_time)) await hass.async_block_till_done() @@ -556,13 +479,7 @@ async def test_device_with_ten_minute_advertising_interval( future_time + advertising_interval + TRACKER_BUFFERING_WOBBLE_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ), patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ): + with patch_bluetooth_time(missed_advertisement_future_time): # Fire once for the scanner to expire the device async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -590,25 +507,6 @@ async def test_scanner_stops_responding( """Test we mark a scanner are not scanning when it stops responding.""" manager = _get_manager() - class FakeScanner(HomeAssistantRemoteScanner): - """A fake remote scanner.""" - - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -626,10 +524,7 @@ async def test_scanner_stops_responding( + SCANNER_WATCHDOG_INTERVAL.total_seconds() ) # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): + with patch_bluetooth_time(failure_reached_time): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -650,11 +545,10 @@ async def test_scanner_stops_responding( failure_reached_time += 1 - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(failure_reached_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, failure_reached_time + ) # As soon as we get a detection, we know the scanner is working again assert scanner.scanning is True diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index c33bfd6db84..7673acb80dc 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -25,6 +25,7 @@ from . import ( async_setup_with_one_adapter, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -226,9 +227,8 @@ async def test_recovery_from_dbus_restart( mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -236,9 +236,8 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 # Fire a callback to reset the timer - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ): _callback( generate_ble_device("44:44:33:11:23:42", "any_name"), @@ -246,9 +245,8 @@ async def test_recovery_from_dbus_restart( ) # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -256,9 +254,8 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 # We hit the timer, so we restart the scanner - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, ): async_fire_time_changed( hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20) @@ -301,9 +298,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, @@ -316,9 +312,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -326,9 +321,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -336,9 +330,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( @@ -390,9 +383,8 @@ async def test_adapter_scanner_fails_to_start_first_time( scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, @@ -405,9 +397,8 @@ async def test_adapter_scanner_fails_to_start_first_time( mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -415,9 +406,8 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -425,9 +415,8 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( @@ -441,9 +430,8 @@ async def test_adapter_scanner_fails_to_start_first_time( # We hit the timer again the previous start call failed, make sure # we try again - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( @@ -504,9 +492,8 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( with patch( "habluetooth.scanner.ADAPTER_INIT_TIME", 0, - ), patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, @@ -555,9 +542,8 @@ async def test_restart_takes_longer_than_watchdog_time( with patch( "habluetooth.scanner.ADAPTER_INIT_TIME", 0, - ), patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, @@ -568,9 +554,8 @@ async def test_restart_takes_longer_than_watchdog_time( # Now force a recover adapter 2x for _ in range(2): - with patch( - "habluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ): From bf8f78c041abb7bbb12b738b5703071eec1b5bf5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Dec 2023 21:15:48 -1000 Subject: [PATCH 523/643] Fix flakey logbook tests (#105111) --- tests/components/logbook/test_init.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index d95b409a67b..671c70168d2 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -5,8 +5,9 @@ from collections.abc import Callable from datetime import datetime, timedelta from http import HTTPStatus import json -from unittest.mock import Mock, patch +from unittest.mock import Mock +from freezegun import freeze_time import pytest import voluptuous as vol @@ -504,10 +505,7 @@ async def test_logbook_describe_event( ) assert await async_setup_component(hass, "logbook", {}) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire("some_event") await async_wait_recording_done(hass) @@ -569,10 +567,7 @@ async def test_exclude_described_event( }, ) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire( "some_automation_event", {logbook.ATTR_NAME: name, logbook.ATTR_ENTITY_ID: entity_id}, From a904461b6a82dbcbd1b9e08c2f9ca79271bc82e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Dec 2023 08:28:02 +0100 Subject: [PATCH 524/643] Bump actions/setup-python from 4.7.1 to 4.8.0 (#105117) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 6 +++--- .github/workflows/ci.yaml | 24 ++++++++++++------------ .github/workflows/translations.yml | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 9d13c07301e..618a9a08d1f 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -124,7 +124,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b9b9c8babb9..4da01579cbb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -225,7 +225,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -269,7 +269,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -309,7 +309,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -348,7 +348,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -443,7 +443,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -511,7 +511,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -543,7 +543,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -576,7 +576,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -702,7 +702,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -854,7 +854,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -978,7 +978,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index f72b71b8802..42d7ea1dd4f 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v4.8.0 with: python-version: ${{ env.DEFAULT_PYTHON }} From ae4811b776893bce76669446837750ae0b1df647 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 5 Dec 2023 11:45:00 -0800 Subject: [PATCH 525/643] Update apple_weatherkit to 1.1.1 (#105079) --- homeassistant/components/weatherkit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index d28a6ff3315..a2ddde02ad4 100644 --- a/homeassistant/components/weatherkit/manifest.json +++ b/homeassistant/components/weatherkit/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherkit", "iot_class": "cloud_polling", - "requirements": ["apple_weatherkit==1.0.4"] + "requirements": ["apple_weatherkit==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0d6a007c2be..ebd3377df33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -437,7 +437,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.1 # homeassistant.components.apprise apprise==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f30a4a90b58..cf3d1a330c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.1 # homeassistant.components.apprise apprise==1.6.0 From f7c9d20472c0ecfaa32e3b827975ca6fa815fa01 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 5 Dec 2023 20:00:53 +0100 Subject: [PATCH 526/643] Fix overkiz measurement sensor returns None if 0 (#105090) --- homeassistant/components/overkiz/sensor.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 0bb9043c040..a267b54b398 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -481,7 +481,12 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): """Return the value of the sensor.""" state = self.device.states.get(self.entity_description.key) - if not state or not state.value: + if ( + state is None + or state.value is None + or self.state_class != SensorStateClass.MEASUREMENT + and not state.value + ): return None # Transform the value with a lambda function From 990fd31e8439c6beec21db73163a8c8859a6bcd4 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Dec 2023 22:16:07 +0100 Subject: [PATCH 527/643] Bump aiounifi to v67 (#105099) * Bump aiounifi to v67 * Fix mypy --- homeassistant/components/unifi/controller.py | 4 ++-- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 6bd8b9db426..035cf66a983 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -5,7 +5,7 @@ import asyncio from datetime import datetime, timedelta import ssl from types import MappingProxyType -from typing import Any +from typing import Any, Literal from aiohttp import CookieJar import aiounifi @@ -458,7 +458,7 @@ async def get_unifi_controller( config: MappingProxyType[str, Any], ) -> aiounifi.Controller: """Create a controller object and verify authentication.""" - ssl_context: ssl.SSLContext | bool = False + ssl_context: ssl.SSLContext | Literal[False] = False if verify_ssl := config.get(CONF_VERIFY_SSL): session = aiohttp_client.async_get_clientsession(hass) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 52ed8ec3101..7d4717d3fff 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==66"], + "requirements": ["aiounifi==67"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index ebd3377df33..a2954cc33f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -374,7 +374,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==66 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf3d1a330c8..e65de1c0177 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==66 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 681a3fd271eb8aaf85129180a2641c2573cc02a6 Mon Sep 17 00:00:00 2001 From: lunmay <28674102+lunmay@users.noreply.github.com> Date: Tue, 5 Dec 2023 22:25:08 +0100 Subject: [PATCH 528/643] Fix typo in v2c strings.json (#105104) fo -> of --- homeassistant/components/v2c/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index dafdd597e77..bf19fe5188e 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -6,7 +6,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address fo your V2C Trydan EVSE." + "host": "Hostname or IP address of your V2C Trydan EVSE." } } }, From da766bc7c5b9403087d2b96927e07b65f55430f0 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 6 Dec 2023 01:14:34 -0600 Subject: [PATCH 529/643] Bump intents to 2023.12.05 (#105116) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2a069d5d92b..cb03499d8e4 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.29"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.12.05"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1b089a57104..c471cd765fd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 home-assistant-frontend==20231205.0 -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index a2954cc33f0..206c7216c47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1017,7 +1017,7 @@ holidays==0.36 home-assistant-frontend==20231205.0 # homeassistant.components.conversation -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e65de1c0177..7a51b943717 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -804,7 +804,7 @@ holidays==0.36 home-assistant-frontend==20231205.0 # homeassistant.components.conversation -home-assistant-intents==2023.11.29 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 From 9fcb72238126fbbb42f650e363181bdf696d8b6c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Dec 2023 08:31:21 +0100 Subject: [PATCH 530/643] Bump version to 2023.12.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 719b661e68f..c240983ce6c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index b207c9f1aa3..4a0be2840b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b4" +version = "2023.12.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 88a4b74d4befead68ea11a7cf0723e2aaa4c1bf8 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Wed, 6 Dec 2023 09:32:10 +0000 Subject: [PATCH 531/643] bump evohome-async to 0.4.15 (#105119) --- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index e8b54eac38e..062bba1cfdc 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.13"] + "requirements": ["evohome-async==0.4.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9fa892ca8c7..918289e627f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -792,7 +792,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.13 +evohome-async==0.4.15 # homeassistant.components.faa_delays faadelays==2023.9.1 From 7424c296b726afc55813c70cf57dbcc86c2becaf Mon Sep 17 00:00:00 2001 From: Tobias Perschon Date: Wed, 6 Dec 2023 11:01:05 +0100 Subject: [PATCH 532/643] Add missing services and strings entries for reply_to_message_id (#105072) --- .../components/telegram_bot/services.yaml | 45 +++++++++++++++++- .../components/telegram_bot/strings.json | 46 ++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 94d1eee1b55..1587f754508 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -34,7 +34,6 @@ send_message: min: 1 max: 3600 unit_of_measurement: seconds - keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -50,6 +49,10 @@ send_message: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_photo: fields: @@ -117,6 +120,10 @@ send_photo: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_sticker: fields: @@ -177,6 +184,10 @@ send_sticker: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_animation: fields: @@ -240,6 +251,14 @@ send_animation: ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box send_video: fields: @@ -307,6 +326,10 @@ send_video: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_voice: fields: @@ -367,6 +390,10 @@ send_voice: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_document: fields: @@ -434,6 +461,10 @@ send_document: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_location: fields: @@ -480,6 +511,10 @@ send_location: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_poll: fields: @@ -516,6 +551,14 @@ send_poll: min: 1 max: 3600 unit_of_measurement: seconds + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box edit_message: fields: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 4dfe0a28d01..de5de685409 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -42,7 +42,11 @@ }, "message_tag": { "name": "Message tag", - "description": "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}." + "description": "Tag for sent message." + }, + "reply_to_message_id": { + "name": "Reply to message id", + "description": "Mark the message as a reply to a previous message." } } }, @@ -105,6 +109,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -163,6 +171,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -221,6 +233,14 @@ "inline_keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -283,6 +303,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -341,6 +365,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -403,6 +431,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -441,6 +473,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -479,6 +515,14 @@ "timeout": { "name": "Timeout", "description": "Timeout for send poll. Will help with timeout errors (poor internet connection, etc)." + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, From 81d05acd07bb260c5f3be7bdd9d66342229873eb Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Wed, 6 Dec 2023 12:39:46 +0100 Subject: [PATCH 533/643] Address late review for Holiday (#105121) --- homeassistant/components/holiday/config_flow.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 93ff2772eb8..1ba4a2a0c26 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -28,7 +28,9 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - data: dict[str, Any] = {} + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -37,7 +39,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: self.data = user_input - selected_country = self.data[CONF_COUNTRY] + selected_country = user_input[CONF_COUNTRY] if SUPPORTED_COUNTRIES[selected_country]: return await self.async_step_province() @@ -46,7 +48,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): locale = Locale(self.hass.config.language) title = locale.territories[selected_country] - return self.async_create_entry(title=title, data=self.data) + return self.async_create_entry(title=title, data=user_input) user_schema = vol.Schema( { From a29695e622ee26d66693b06880fd74437536228e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 6 Dec 2023 14:23:26 +0200 Subject: [PATCH 534/643] Add Huawei LTE network mode select (#104614) * Convert network mode from sensor to select for huawei_lte This also introduces the select platform to huawei_lte integration. * Move (networkmode, str) mapping to const Also, rebase on top of the current dev * Fix variable naming, initialize name * Fix wrong key for router access * Typing fixes * Adapt to current way of registering subscriptions * Simplify option management, make translatable * Make use of custom entity description * Add icon * Revert sensor formatting changes, move to another PR * Improve entity class naming * Add test * Make sure entity descriptions define a setter function --------- Co-authored-by: Teemu Rytilahti --- .../components/huawei_lte/__init__.py | 1 + homeassistant/components/huawei_lte/select.py | 132 ++++++++++++++++++ .../components/huawei_lte/strings.json | 14 ++ tests/components/huawei_lte/test_select.py | 43 ++++++ 4 files changed, 190 insertions(+) create mode 100644 homeassistant/components/huawei_lte/select.py create mode 100644 tests/components/huawei_lte/test_select.py diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index d8c939e5c3a..dcd40b8346c 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -135,6 +135,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH, + Platform.SELECT, ] diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py new file mode 100644 index 00000000000..2f4b7274fc0 --- /dev/null +++ b/homeassistant/components/huawei_lte/select.py @@ -0,0 +1,132 @@ +"""Support for Huawei LTE selects.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from functools import partial +import logging + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED + +from . import HuaweiLteBaseEntityWithDevice +from .const import DOMAIN, KEY_NET_NET_MODE + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class HuaweiSelectEntityMixin: + """Mixin for Huawei LTE select entities, to ensure required fields are set.""" + + setter_fn: Callable[[str], None] + + +@dataclass +class HuaweiSelectEntityDescription(SelectEntityDescription, HuaweiSelectEntityMixin): + """Class describing Huawei LTE select entities.""" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.entry_id] + selects: list[Entity] = [] + + desc = HuaweiSelectEntityDescription( + key=KEY_NET_NET_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:transmission-tower", + name="Preferred network mode", + translation_key="preferred_network_mode", + options=[ + NetworkModeEnum.MODE_AUTO.value, + NetworkModeEnum.MODE_4G_3G_AUTO.value, + NetworkModeEnum.MODE_4G_2G_AUTO.value, + NetworkModeEnum.MODE_4G_ONLY.value, + NetworkModeEnum.MODE_3G_2G_AUTO.value, + NetworkModeEnum.MODE_3G_ONLY.value, + NetworkModeEnum.MODE_2G_ONLY.value, + ], + setter_fn=partial( + router.client.net.set_net_mode, + LTEBandEnum.ALL, + NetworkBandEnum.ALL, + ), + ) + selects.append( + HuaweiLteSelectEntity( + router, + entity_description=desc, + key=desc.key, + item="NetworkMode", + ) + ) + + async_add_entities(selects, True) + + +@dataclass +class HuaweiLteSelectEntity(HuaweiLteBaseEntityWithDevice, SelectEntity): + """Huawei LTE select entity.""" + + entity_description: HuaweiSelectEntityDescription + key: str + item: str + + _raw_state: str | None = field(default=None, init=False) + + def __post_init__(self) -> None: + """Initialize remaining attributes.""" + name = None + if self.entity_description.name != UNDEFINED: + name = self.entity_description.name + self._attr_name = name or self.item + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self.entity_description.setter_fn(option) + + @property + def current_option(self) -> str | None: + """Return current option.""" + return self._raw_state + + @property + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" + + async def async_added_to_hass(self) -> None: + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].append(f"{SELECT_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove(f"{SELECT_DOMAIN}/{self.item}") + + async def async_update(self) -> None: + """Update state.""" + try: + value = self.router.data[self.key][self.item] + except KeyError: + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True + self._raw_state = str(value) diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 754f192e57e..225146799a3 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -286,6 +286,20 @@ "name": "SMS messages (SIM)" } }, + "select": { + "preferred_network_mode": { + "name": "Preferred network mode", + "state": { + "00": "4G/3G/2G auto", + "0302": "4G/3G auto", + "0301": "4G/2G auto", + "03": "4G only", + "0201": "3G/2G auto", + "02": "3G only", + "01": "2G only" + } + } + }, "switch": { "mobile_data": { "name": "Mobile data" diff --git a/tests/components/huawei_lte/test_select.py b/tests/components/huawei_lte/test_select.py new file mode 100644 index 00000000000..c3f6ded65b6 --- /dev/null +++ b/tests/components/huawei_lte/test_select.py @@ -0,0 +1,43 @@ +"""Tests for the Huawei LTE selects.""" +from unittest.mock import MagicMock, patch + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.huawei_lte.const import DOMAIN +from homeassistant.components.select import SERVICE_SELECT_OPTION +from homeassistant.components.select.const import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_URL +from homeassistant.core import HomeAssistant + +from . import magic_client + +from tests.common import MockConfigEntry + +SELECT_NETWORK_MODE = "select.lte_preferred_network_mode" + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client") +async def test_set_net_mode(client, hass: HomeAssistant) -> None: + """Test setting network mode.""" + client.return_value = magic_client({}) + huawei_lte = MockConfigEntry( + domain=DOMAIN, data={CONF_URL: "http://huawei-lte.example.com"} + ) + huawei_lte.add_to_hass(hass) + await hass.config_entries.async_setup(huawei_lte.entry_id) + await hass.async_block_till_done() + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: SELECT_NETWORK_MODE, + ATTR_OPTION: NetworkModeEnum.MODE_4G_3G_AUTO.value, + }, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.net.set_net_mode.assert_called_once() + client.return_value.net.set_net_mode.assert_called_with( + LTEBandEnum.ALL, NetworkBandEnum.ALL, NetworkModeEnum.MODE_4G_3G_AUTO.value + ) From eb00259356c3d092e8587c65812b8c9db47891ed Mon Sep 17 00:00:00 2001 From: Xidorn Quan Date: Wed, 6 Dec 2023 23:30:31 +1100 Subject: [PATCH 535/643] Bump thermopro-ble to 0.5.0 (#105126) --- homeassistant/components/thermopro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index b48760f773d..a0a07d3cb00 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.4.5"] + "requirements": ["thermopro-ble==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 918289e627f..9b13ce0a6bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2601,7 +2601,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.thermoworks_smoke thermoworks-smoke==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c24e61c2204..7ce8200d1e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From 3f28354a00013b2a501ea82c2517c8dc54f6da2d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Dec 2023 14:39:27 +0100 Subject: [PATCH 536/643] Fix missing target in todo.remove_completed_items service (#105127) --- homeassistant/components/todo/services.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index bc7da7db941..8ecc9e0ec86 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -86,3 +86,8 @@ remove_item: text: remove_completed_items: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.DELETE_TODO_ITEM From a16819e0e5ed5b806101aa7391f36b7ae45f300c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 6 Dec 2023 14:43:26 +0100 Subject: [PATCH 537/643] Use freezegun in utility_meter tests (#105123) --- tests/components/utility_meter/test_init.py | 14 +++++++------- tests/components/utility_meter/test_sensor.py | 15 +++++++-------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 5c8d8d4253c..0ac8140c52d 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -2,8 +2,8 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.components.select import ( @@ -95,7 +95,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -116,7 +116,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -144,7 +144,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, @@ -221,7 +221,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -242,7 +242,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -270,7 +270,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 2c64338c4f3..d77c2db356a 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1,6 +1,5 @@ """The tests for the utility_meter sensor platform.""" from datetime import timedelta -from unittest.mock import patch from freezegun import freeze_time import pytest @@ -132,7 +131,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -166,7 +165,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, @@ -729,7 +728,7 @@ async def test_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -803,7 +802,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -813,7 +812,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, None, @@ -1148,7 +1147,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -1186,7 +1185,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get("status") == COLLECTING now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, From 297a7638ca13a3f1bd75d1737783c84fc6334cbb Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 6 Dec 2023 14:51:36 +0100 Subject: [PATCH 538/643] Update frontend to 20231206.0 (#105132) --- 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 08eb0f0a424..af2ea6f9149 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==20231205.0"] + "requirements": ["home-assistant-frontend==20231206.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 10d041790a9..1673877b029 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ habluetooth==0.8.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9b13ce0a6bf..381ab657761 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1024,7 +1024,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ce8200d1e7..87c15ed0f81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -811,7 +811,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From 6721f9fdb2577a72fcec801fe96f1a39cec2a4e0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Dec 2023 16:21:12 +0100 Subject: [PATCH 539/643] Bump python-opensky to 1.0.0 (#105131) --- homeassistant/components/opensky/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index d33dfec6adf..106103cf752 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.2.1"] + "requirements": ["python-opensky==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 381ab657761..63fc084db6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2191,7 +2191,7 @@ python-mystrom==2.2.0 python-opendata-transport==0.3.0 # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87c15ed0f81..c9ea5df6ce7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1639,7 +1639,7 @@ python-miio==0.5.12 python-mystrom==2.2.0 # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread From c93abd9d202f1d0efe3b0da197e2ec07214ce742 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Dec 2023 16:22:32 +0100 Subject: [PATCH 540/643] Improve decorator type annotations [zwave_js] (#104825) * Improve decorator type annotations [zwave_js] * Improve _async_get_entry annotation --- homeassistant/components/zwave_js/api.py | 53 +++++++++++++++----- homeassistant/components/zwave_js/helpers.py | 4 +- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 9e50b55830c..7f4855bfbe5 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1,10 +1,10 @@ """Websocket API for Z-Wave JS.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import dataclasses from functools import partial, wraps -from typing import Any, Literal, cast +from typing import Any, Concatenate, Literal, ParamSpec, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -85,6 +85,8 @@ from .helpers import ( get_device_id, ) +_P = ParamSpec("_P") + DATA_UNSUBSCRIBE = "unsubs" # general API constants @@ -264,8 +266,11 @@ QR_CODE_STRING_SCHEMA = vol.All(str, vol.Length(min=MINIMUM_QR_STRING_LENGTH)) async def _async_get_entry( - hass: HomeAssistant, connection: ActiveConnection, msg: dict, entry_id: str -) -> tuple[ConfigEntry | None, Client | None, Driver | None]: + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry_id: str, +) -> tuple[ConfigEntry, Client, Driver] | tuple[None, None, None]: """Get config entry and client from message data.""" entry = hass.config_entries.async_get_entry(entry_id) if entry is None: @@ -293,19 +298,26 @@ async def _async_get_entry( return entry, client, client.driver -def async_get_entry(orig_func: Callable) -> Callable: +def async_get_entry( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry, Client, Driver], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get entry.""" @wraps(orig_func) async def async_get_entry_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" entry, client, driver = await _async_get_entry( hass, connection, msg, msg[ENTRY_ID] ) - if not entry and not client and not driver: + if not entry or not client or not driver: return await orig_func(hass, connection, msg, entry, client, driver) @@ -328,12 +340,19 @@ async def _async_get_node( return node -def async_get_node(orig_func: Callable) -> Callable: +def async_get_node( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], Node], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get node.""" @wraps(orig_func) async def async_get_node_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" node = await _async_get_node(hass, connection, msg, msg[DEVICE_ID]) @@ -344,16 +363,24 @@ def async_get_node(orig_func: Callable) -> Callable: return async_get_node_func -def async_handle_failed_command(orig_func: Callable) -> Callable: +def async_handle_failed_command( + orig_func: Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], + ], +) -> Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], +]: """Decorate async function to handle FailedCommand and send relevant error.""" @wraps(orig_func) async def async_handle_failed_command_func( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, - *args: Any, - **kwargs: Any, + msg: dict[str, Any], + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: """Handle FailedCommand within function and send relevant error.""" try: diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 5d78d3e57e7..65c77f8ab2d 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -456,7 +456,9 @@ def remove_keys_with_empty_values(config: ConfigType) -> ConfigType: return {key: value for key, value in config.items() if value not in ("", None)} -def check_type_schema_map(schema_map: dict[str, vol.Schema]) -> Callable: +def check_type_schema_map( + schema_map: dict[str, vol.Schema] +) -> Callable[[ConfigType], ConfigType]: """Check type specific schema against config.""" def _check_type_schema(config: ConfigType) -> ConfigType: From 6b3e9904c8a30d1c47e76812f3b95f529a407a6c Mon Sep 17 00:00:00 2001 From: Tobias Perschon Date: Wed, 6 Dec 2023 11:01:05 +0100 Subject: [PATCH 541/643] Add missing services and strings entries for reply_to_message_id (#105072) --- .../components/telegram_bot/services.yaml | 45 +++++++++++++++++- .../components/telegram_bot/strings.json | 46 ++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 94d1eee1b55..1587f754508 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -34,7 +34,6 @@ send_message: min: 1 max: 3600 unit_of_measurement: seconds - keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -50,6 +49,10 @@ send_message: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_photo: fields: @@ -117,6 +120,10 @@ send_photo: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_sticker: fields: @@ -177,6 +184,10 @@ send_sticker: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_animation: fields: @@ -240,6 +251,14 @@ send_animation: ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box send_video: fields: @@ -307,6 +326,10 @@ send_video: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_voice: fields: @@ -367,6 +390,10 @@ send_voice: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_document: fields: @@ -434,6 +461,10 @@ send_document: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_location: fields: @@ -480,6 +511,10 @@ send_location: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_poll: fields: @@ -516,6 +551,14 @@ send_poll: min: 1 max: 3600 unit_of_measurement: seconds + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box edit_message: fields: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 4dfe0a28d01..de5de685409 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -42,7 +42,11 @@ }, "message_tag": { "name": "Message tag", - "description": "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}." + "description": "Tag for sent message." + }, + "reply_to_message_id": { + "name": "Reply to message id", + "description": "Mark the message as a reply to a previous message." } } }, @@ -105,6 +109,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -163,6 +171,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -221,6 +233,14 @@ "inline_keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -283,6 +303,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -341,6 +365,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -403,6 +431,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -441,6 +473,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -479,6 +515,14 @@ "timeout": { "name": "Timeout", "description": "Timeout for send poll. Will help with timeout errors (poor internet connection, etc)." + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, From e165d6741eb922162c848009ac5188c816cbbb7c Mon Sep 17 00:00:00 2001 From: Xidorn Quan Date: Wed, 6 Dec 2023 23:30:31 +1100 Subject: [PATCH 542/643] Bump thermopro-ble to 0.5.0 (#105126) --- homeassistant/components/thermopro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index b48760f773d..a0a07d3cb00 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.4.5"] + "requirements": ["thermopro-ble==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 206c7216c47..5aa8ce31266 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2591,7 +2591,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.thermoworks_smoke thermoworks-smoke==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a51b943717..fa692c71c0f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1928,7 +1928,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From 0958e8fadf233986281a7142d063847cabb57d41 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Dec 2023 14:39:27 +0100 Subject: [PATCH 543/643] Fix missing target in todo.remove_completed_items service (#105127) --- homeassistant/components/todo/services.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index bc7da7db941..8ecc9e0ec86 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -86,3 +86,8 @@ remove_item: text: remove_completed_items: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.DELETE_TODO_ITEM From d8b056b3407e5ffe25adad1b422816599d196c6d Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 6 Dec 2023 14:51:36 +0100 Subject: [PATCH 544/643] Update frontend to 20231206.0 (#105132) --- 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 08eb0f0a424..af2ea6f9149 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==20231205.0"] + "requirements": ["home-assistant-frontend==20231206.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c471cd765fd..e8e45a9393e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-ffmpeg==3.1.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5aa8ce31266..fda92edee3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1014,7 +1014,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa692c71c0f..675cfa7c646 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ hole==0.8.0 holidays==0.36 # homeassistant.components.frontend -home-assistant-frontend==20231205.0 +home-assistant-frontend==20231206.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From af23580530e0fd5b9d46805e2b013567fbfc5d7f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Dec 2023 16:31:24 +0100 Subject: [PATCH 545/643] Bump version to 2023.12.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c240983ce6c..8267fd29390 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 4a0be2840b2..b6bb8649b03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0b5" +version = "2023.12.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ff21c02cb6373fe662c086579ffd659dff9bc94d Mon Sep 17 00:00:00 2001 From: Tucker Kern Date: Wed, 6 Dec 2023 09:53:52 -0700 Subject: [PATCH 546/643] Add preset modes to ESPHome fan entities (#103781) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/fan.py | 17 +++++++++++++++++ tests/components/esphome/test_fan.py | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 9942498e12d..08135e1a702 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -105,6 +105,10 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) ) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + await self._client.fan_command(key=self._key, preset_mode=preset_mode) + @property @esphome_state_property def is_on(self) -> bool | None: @@ -144,6 +148,17 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """Return the current fan direction.""" return _FAN_DIRECTIONS.from_esphome(self._state.direction) + @property + @esphome_state_property + def preset_mode(self) -> str | None: + """Return the current fan preset mode.""" + return self._state.preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return the supported fan preset modes.""" + return self._static_info.supported_preset_modes + @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: """Set attrs from static info.""" @@ -156,4 +171,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): flags |= FanEntityFeature.SET_SPEED if static_info.supports_direction: flags |= FanEntityFeature.DIRECTION + if static_info.supported_preset_modes: + flags |= FanEntityFeature.PRESET_MODE self._attr_supported_features = flags diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 99f4bbc86a9..6f383dcb6ba 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -16,12 +16,14 @@ from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, + ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN, SERVICE_DECREASE_SPEED, SERVICE_INCREASE_SPEED, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, @@ -145,6 +147,7 @@ async def test_fan_entity_with_all_features_new_api( supports_direction=True, supports_speed=True, supports_oscillation=True, + supported_preset_modes=["Preset1", "Preset2"], ) ] states = [ @@ -154,6 +157,7 @@ async def test_fan_entity_with_all_features_new_api( oscillating=True, speed_level=3, direction=FanDirection.REVERSE, + preset_mode=None, ) ] user_service = [] @@ -270,6 +274,15 @@ async def test_fan_entity_with_all_features_new_api( ) mock_client.fan_command.reset_mock() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PRESET_MODE: "Preset1"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, preset_mode="Preset1")]) + mock_client.fan_command.reset_mock() + async def test_fan_entity_with_no_features_new_api( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry @@ -285,6 +298,7 @@ async def test_fan_entity_with_no_features_new_api( supports_direction=False, supports_speed=False, supports_oscillation=False, + supported_preset_modes=[], ) ] states = [FanState(key=1, state=True)] From 32febcda5a81af205ad66582e1740ed1d7ea2fcc Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Wed, 6 Dec 2023 09:36:46 -0800 Subject: [PATCH 547/643] Bump apple_weatherkit to 1.1.2 (#105140) --- homeassistant/components/weatherkit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index a2ddde02ad4..a6dd40d5993 100644 --- a/homeassistant/components/weatherkit/manifest.json +++ b/homeassistant/components/weatherkit/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherkit", "iot_class": "cloud_polling", - "requirements": ["apple_weatherkit==1.1.1"] + "requirements": ["apple_weatherkit==1.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 63fc084db6a..564836737e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -437,7 +437,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.1.1 +apple_weatherkit==1.1.2 # homeassistant.components.apprise apprise==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9ea5df6ce7..5d4bb6ac9b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.1.1 +apple_weatherkit==1.1.2 # homeassistant.components.apprise apprise==1.6.0 From 05e122e22b32113e39ae8f8e2b3e405ba796b7e2 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 6 Dec 2023 18:46:35 +0100 Subject: [PATCH 548/643] Modernize and cleanup trend tests (#105010) Co-authored-by: Em --- tests/components/trend/test_binary_sensor.py | 615 +++++++------------ 1 file changed, 223 insertions(+), 392 deletions(-) diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index 1906c002101..b525c7a8fa3 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -1,398 +1,247 @@ """The test for the Trend sensor platform.""" from datetime import timedelta import logging +from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import config as hass_config, setup from homeassistant.components.trend.const import DOMAIN -from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN +from homeassistant.const import SERVICE_RELOAD, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.setup import async_setup_component -from tests.common import ( - assert_setup_component, - get_fixture_path, - get_test_home_assistant, - mock_restore_cache, +from tests.common import assert_setup_component, get_fixture_path, mock_restore_cache + + +async def _setup_component(hass: HomeAssistant, params: dict[str, Any]) -> None: + """Set up the trend component.""" + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": { + "test_trend_sensor": params, + }, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("states", "inverted", "expected_state"), + [ + (["1", "2"], False, STATE_ON), + (["2", "1"], False, STATE_OFF), + (["1", "2"], True, STATE_OFF), + (["2", "1"], True, STATE_ON), + ], + ids=["up", "down", "up inverted", "down inverted"], ) +async def test_basic_trend( + hass: HomeAssistant, + states: list[str], + inverted: bool, + expected_state: str, +): + """Test trend with a basic setup.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "invert": inverted, + }, + ) + + for state in states: + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state -class TestTrendBinarySensor: - """Test the Trend sensor.""" +@pytest.mark.parametrize( + ("state_series", "inverted", "expected_states"), + [ + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + False, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + True, + [STATE_UNKNOWN, STATE_OFF, STATE_ON], + ), + ( + [[30, 20, 30, 10], [5], [30, 0, 45, 60]], + True, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ], + ids=["up", "up inverted", "down"], +) +async def test_using_trendline( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + state_series: list[list[str]], + inverted: bool, + expected_states: list[str], +): + """Test uptrend using multiple samples and trendline calculation.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + "invert": inverted, + }, + ) - hass = None + for idx, states in enumerate(state_series): + for state in states: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_states[idx] - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - def test_up(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, +@pytest.mark.parametrize( + ("attr_values", "expected_state"), + [ + (["1", "2"], STATE_ON), + (["2", "1"], STATE_OFF), + ], + ids=["up", "down"], +) +async def test_attribute_trend( + hass: HomeAssistant, + attr_values: list[str], + expected_state: str, +): + """Test attribute uptrend.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "attribute": "attr", + }, + ) + + for attr in attr_values: + hass.states.async_set("sensor.test_state", "State", {"attr": attr}) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state + + +async def test_max_samples(hass: HomeAssistant): + """Test that sample count is limited correctly.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "max_samples": 3, + "min_gradient": -1, + }, + ) + + for val in [0, 1, 2, 3, 2, 1]: + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == "on" + assert state.attributes["sample_count"] == 3 + + +async def test_non_numeric(hass: HomeAssistant): + """Test for non-numeric sensor.""" + await _setup_component(hass, {"entity_id": "sensor.test_state"}) + + hass.states.async_set("sensor.test_state", "Non") + await hass.async_block_till_done() + hass.states.async_set("sensor.test_state", "Numeric") + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN + + +async def test_missing_attribute(hass: HomeAssistant): + """Test for missing attribute.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "attribute": "missing", + }, + ) + + hass.states.async_set("sensor.test_state", "State", {"attr": "2"}) + await hass.async_block_till_done() + hass.states.async_set("sensor.test_state", "State", {"attr": "1"}) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN + + +async def test_invalid_name_does_not_create(hass: HomeAssistant): + """Test for invalid name.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", { "binary_sensor": { "platform": "trend", "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} + "test INVALID sensor": {"entity_id": "sensor.test_state"} }, } }, ) - self.hass.block_till_done() + assert hass.states.async_all("binary_sensor") == [] - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - def test_up_using_trendline(self): - """Test up trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, +async def test_invalid_sensor_does_not_create(hass: HomeAssistant): + """Test invalid sensor.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", { "binary_sensor": { "platform": "trend", "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "min_samples": 5, - } + "test_trend_sensor": {"not_entity_id": "sensor.test_state"} }, } }, ) - self.hass.block_till_done() + assert hass.states.async_all("binary_sensor") == [] - now = dt_util.utcnow() - # add not enough states to trigger calculation - for val in [10, 0, 20, 30]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - assert ( - self.hass.states.get("binary_sensor.test_trend_sensor").state == "unknown" +async def test_no_sensors_does_not_create(hass: HomeAssistant): + """Test no sensors.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} ) - - # add one more state to trigger gradient calculation - for val in [100]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "on" - - # add more states to trigger a downtrend - for val in [0, 30, 1, 0]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "off" - - def test_down_using_trendline(self): - """Test down trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - now = dt_util.utcnow() - for val in [30, 20, 30, 10]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - for val in [30, 0, 45, 50]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_down(self): - """Test down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_up(self): - """Test up trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_down(self): - """Test down trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_up(self): - """Test attribute up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_down(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_max_samples(self): - """Test that sample count is limited correctly.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "max_samples": 3, - "min_gradient": -1, - } - }, - } - }, - ) - self.hass.block_till_done() - - for val in [0, 1, 2, 3, 2, 1]: - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - assert state.attributes["sample_count"] == 3 - - def test_non_numeric(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "Non") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "Numeric") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_missing_attribute(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "missing", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_invalid_name_does_not_create(self): - """Test invalid name.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test INVALID sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_invalid_sensor_does_not_create(self): - """Test invalid sensor.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_trend_sensor": {"not_entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_no_sensors_does_not_create(self): - """Test no sensors.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} - ) - assert self.hass.states.all("binary_sensor") == [] + assert hass.states.async_all("binary_sensor") == [] async def test_reload(hass: HomeAssistant) -> None: @@ -436,79 +285,61 @@ async def test_reload(hass: HomeAssistant) -> None: [("on", "on"), ("off", "off"), ("unknown", "unknown")], ) async def test_restore_state( - hass: HomeAssistant, saved_state: str, restored_state: str + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + saved_state: str, + restored_state: str, ) -> None: """Test we restore the trend state.""" mock_restore_cache(hass, (State("binary_sensor.test_trend_sensor", saved_state),)) - assert await setup.async_setup_component( + await _setup_component( hass, - "binary_sensor", { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "min_samples": 5, - } - }, - } + "entity_id": "sensor.test_state", + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, }, ) - await hass.async_block_till_done() # restored sensor should match saved one assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state - now = dt_util.utcnow() - # add not enough samples to trigger calculation for val in [10, 20, 30, 40]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set("sensor.test_state", val) + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) await hass.async_block_till_done() - now += timedelta(seconds=2) # state should match restored state as no calculation happened assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state # add more samples to trigger calculation for val in [50, 60, 70, 80]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set("sensor.test_state", val) + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) await hass.async_block_till_done() - now += timedelta(seconds=2) # sensor should detect an upwards trend and turn on assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" async def test_invalid_min_sample( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if error is logged when min_sample is larger than max_samples.""" with caplog.at_level(logging.ERROR): - assert await setup.async_setup_component( + await _setup_component( hass, - "binary_sensor", { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "max_samples": 25, - "min_samples": 30, - } - }, - } + "entity_id": "sensor.test_state", + "max_samples": 25, + "min_samples": 30, }, ) - await hass.async_block_till_done() record = caplog.records[0] assert record.levelname == "ERROR" From 16f6a579247807e6133025a9353c951b01d007ea Mon Sep 17 00:00:00 2001 From: lunmay <28674102+lunmay@users.noreply.github.com> Date: Thu, 7 Dec 2023 07:12:27 +0100 Subject: [PATCH 549/643] Fix missing apostrophe in smtp (#105189) Fix missing apostrophe --- homeassistant/components/smtp/notify.py | 4 ++-- homeassistant/components/smtp/strings.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index dcc2f49db0f..87600650551 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -263,8 +263,8 @@ def _attach_file(hass, atch_name, content_id=""): file_name = os.path.basename(atch_name) url = "https://www.home-assistant.io/docs/configuration/basic/" raise ServiceValidationError( - f"Cannot send email with attachment '{file_name} " - f"from directory '{file_path} which is not secure to load data from. " + f"Cannot send email with attachment '{file_name}' " + f"from directory '{file_path}' which is not secure to load data from. " f"Only folders added to `{allow_list}` are accessible. " f"See {url} for more information.", translation_domain=DOMAIN, diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 38dd81ac196..e8347ef1a89 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -7,7 +7,7 @@ }, "exceptions": { "remote_path_not_allowed": { - "message": "Cannot send email with attachment '{file_name} form directory '{file_path} which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." + "message": "Cannot send email with attachment '{file_name}' form directory '{file_path}' which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." } } } From 9181933619c5e8f28a355056593bffc6428413c2 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 7 Dec 2023 07:15:31 +0100 Subject: [PATCH 550/643] Fix ZHA quirk ID custom entities matching all devices (#105184) --- homeassistant/components/zha/core/registries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 4bdedebfff9..87f59f31e9b 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -253,7 +253,7 @@ class MatchRule: else: matches.append(model in self.models) - if self.quirk_ids and quirk_id: + if self.quirk_ids: if callable(self.quirk_ids): matches.append(self.quirk_ids(quirk_id)) else: From 4666d7e17f0db13c3529b54d1ee8f400abed4efe Mon Sep 17 00:00:00 2001 From: Matrix Date: Thu, 7 Dec 2023 14:30:27 +0800 Subject: [PATCH 551/643] Bump yolink-api to 0.3.4 (#105124) * Bump yolink-api to 0.3.3 * bump yolink api to 0.3.4 --- homeassistant/components/yolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 7322c58ae04..a42687a3551 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.3.1"] + "requirements": ["yolink-api==0.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 564836737e9..efd8d4e6c81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2802,7 +2802,7 @@ yeelight==0.7.14 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.3.1 +yolink-api==0.3.4 # homeassistant.components.youless youless-api==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d4bb6ac9b1..24de015fd25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2100,7 +2100,7 @@ yalexs==1.10.0 yeelight==0.7.14 # homeassistant.components.yolink -yolink-api==0.3.1 +yolink-api==0.3.4 # homeassistant.components.youless youless-api==1.0.1 From 0b6665ed09ae4b9af3f0664cb286dc00699478a7 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 7 Dec 2023 07:39:37 +0100 Subject: [PATCH 552/643] Bump reolink_aio to 0.8.2 (#105157) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5ffbc2fb186..e03fa28b7ce 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.8.1"] + "requirements": ["reolink-aio==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index efd8d4e6c81..c18b222251f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2348,7 +2348,7 @@ renault-api==0.2.0 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.1 +reolink-aio==0.8.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24de015fd25..bdba3f3629d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1760,7 +1760,7 @@ renault-api==0.2.0 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.1 +reolink-aio==0.8.2 # homeassistant.components.rflink rflink==0.0.65 From 22119a2fd86658cf272032651e5701e90fdad6dd Mon Sep 17 00:00:00 2001 From: Sebastian Nohn Date: Thu, 7 Dec 2023 07:44:19 +0100 Subject: [PATCH 553/643] Set ping interval to 15 seconds instead of 5 minutes (#105191) set ping interval to a more sane value of 15 seconds instead of 5 minutes. fixes home-assistant/core#105163 --- homeassistant/components/ping/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py index dadd105b606..5fe9d692bc3 100644 --- a/homeassistant/components/ping/coordinator.py +++ b/homeassistant/components/ping/coordinator.py @@ -40,7 +40,7 @@ class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): hass, _LOGGER, name=f"Ping {ping.ip_address}", - update_interval=timedelta(minutes=5), + update_interval=timedelta(seconds=15), ) async def _async_update_data(self) -> PingResult: From e051244927dfae578d4c1bb00de726f121dff3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=ADan=20Hughes?= Date: Thu, 7 Dec 2023 07:19:03 +0000 Subject: [PATCH 554/643] Add Modbus fan speed support (#104577) Co-authored-by: jan iversen --- homeassistant/components/modbus/__init__.py | 33 +++ homeassistant/components/modbus/climate.py | 87 ++++++- homeassistant/components/modbus/const.py | 14 +- homeassistant/components/modbus/validators.py | 58 ++++- tests/components/modbus/test_climate.py | 220 +++++++++++++++++- tests/components/modbus/test_init.py | 191 ++++++++++++++- 6 files changed, 587 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 46bb5b83731..74a1de48c0a 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -63,6 +63,18 @@ from .const import ( # noqa: F401 CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_FANS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, @@ -122,6 +134,7 @@ from .const import ( # noqa: F401 from .modbus import ModbusHub, async_modbus_setup from .validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -265,6 +278,26 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), + vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( + vol.All( + { + CONF_ADDRESS: cv.positive_int, + CONF_FAN_MODE_VALUES: { + vol.Optional(CONF_FAN_MODE_ON): cv.positive_int, + vol.Optional(CONF_FAN_MODE_OFF): cv.positive_int, + vol.Optional(CONF_FAN_MODE_AUTO): cv.positive_int, + vol.Optional(CONF_FAN_MODE_LOW): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MEDIUM): cv.positive_int, + vol.Optional(CONF_FAN_MODE_HIGH): cv.positive_int, + vol.Optional(CONF_FAN_MODE_TOP): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MIDDLE): cv.positive_int, + vol.Optional(CONF_FAN_MODE_FOCUS): cv.positive_int, + vol.Optional(CONF_FAN_MODE_DIFFUSE): cv.positive_int, + }, + }, + duplicate_fan_mode_validator, + ), + ), } ), ) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 5de08803cd4..76132014413 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -6,6 +6,16 @@ import struct from typing import Any, cast from homeassistant.components.climate import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -31,6 +41,18 @@ from .const import ( CALL_TYPE_WRITE_REGISTER, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -138,6 +160,42 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.AUTO] + if CONF_FAN_MODE_REGISTER in config: + self._attr_supported_features = ( + self._attr_supported_features | ClimateEntityFeature.FAN_MODE + ) + mode_config = config[CONF_FAN_MODE_REGISTER] + self._fan_mode_register = mode_config[CONF_ADDRESS] + self._attr_fan_modes = cast(list[str], []) + self._attr_fan_mode = None + self._fan_mode_mapping_to_modbus: dict[str, int] = {} + self._fan_mode_mapping_from_modbus: dict[int, str] = {} + mode_value_config = mode_config[CONF_FAN_MODE_VALUES] + + for fan_mode_kw, fan_mode in ( + (CONF_FAN_MODE_ON, FAN_ON), + (CONF_FAN_MODE_OFF, FAN_OFF), + (CONF_FAN_MODE_AUTO, FAN_AUTO), + (CONF_FAN_MODE_LOW, FAN_LOW), + (CONF_FAN_MODE_MEDIUM, FAN_MEDIUM), + (CONF_FAN_MODE_HIGH, FAN_HIGH), + (CONF_FAN_MODE_TOP, FAN_TOP), + (CONF_FAN_MODE_MIDDLE, FAN_MIDDLE), + (CONF_FAN_MODE_FOCUS, FAN_FOCUS), + (CONF_FAN_MODE_DIFFUSE, FAN_DIFFUSE), + ): + if fan_mode_kw in mode_value_config: + value = mode_value_config[fan_mode_kw] + self._fan_mode_mapping_from_modbus[value] = fan_mode + self._fan_mode_mapping_to_modbus[fan_mode] = value + self._attr_fan_modes.append(fan_mode) + + else: + # No HVAC modes defined + self._fan_mode_register = None + self._attr_fan_mode = FAN_AUTO + self._attr_fan_modes = [FAN_AUTO] + if CONF_HVAC_ONOFF_REGISTER in config: self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] self._hvac_onoff_write_registers = config[CONF_WRITE_REGISTERS] @@ -194,6 +252,21 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): await self.async_update() + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + + if self._fan_mode_register is not None: + # Write a value to the mode register for the desired mode. + value = self._fan_mode_mapping_to_modbus[fan_mode] + await self._hub.async_pb_call( + self._slave, + self._fan_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) + + await self.async_update() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_temperature = ( @@ -255,7 +328,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._input_type, self._address ) - # Read the mode register if defined + # Read the HVAC mode register if defined if self._hvac_mode_register is not None: hvac_mode = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._hvac_mode_register, raw=True @@ -269,7 +342,17 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = mode break - # Read th on/off register if defined. If the value in this + # Read the Fan mode register if defined + if self._fan_mode_register is not None: + fan_mode = await self._async_read_register( + CALL_TYPE_REGISTER_HOLDING, self._fan_mode_register, raw=True + ) + + # Translate the value received + if fan_mode is not None: + self._attr_fan_mode = self._fan_mode_mapping_from_modbus[int(fan_mode)] + + # Read the on/off register if defined. If the value in this # register is "OFF", it will take precedence over the value # in the mode register. if self._hvac_onoff_register is not None: diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 745793e4057..e536a31c4f6 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -49,8 +49,19 @@ CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" CONF_TARGET_TEMP_WRITE_REGISTERS = "target_temp_write_registers" +CONF_FAN_MODE_REGISTER = "fan_mode_register" +CONF_FAN_MODE_ON = "state_fan_on" +CONF_FAN_MODE_OFF = "state_fan_off" +CONF_FAN_MODE_LOW = "state_fan_low" +CONF_FAN_MODE_MEDIUM = "state_fan_medium" +CONF_FAN_MODE_HIGH = "state_fan_high" +CONF_FAN_MODE_AUTO = "state_fan_auto" +CONF_FAN_MODE_TOP = "state_fan_top" +CONF_FAN_MODE_MIDDLE = "state_fan_middle" +CONF_FAN_MODE_FOCUS = "state_fan_focus" +CONF_FAN_MODE_DIFFUSE = "state_fan_diffuse" +CONF_FAN_MODE_VALUES = "values" CONF_HVAC_MODE_REGISTER = "hvac_mode_register" -CONF_HVAC_MODE_VALUES = "values" CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" @@ -59,6 +70,7 @@ CONF_HVAC_MODE_HEAT_COOL = "state_heat_cool" CONF_HVAC_MODE_AUTO = "state_auto" CONF_HVAC_MODE_DRY = "state_dry" CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" +CONF_HVAC_MODE_VALUES = "values" CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" CONF_VIRTUAL_COUNT = "virtual_count" diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index eaf787b3010..7dc5a91a2fa 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -26,12 +26,16 @@ from homeassistant.const import ( from .const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_REGISTER, CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, DEFAULT_HUB, @@ -264,12 +268,31 @@ def duplicate_entity_validator(config: dict) -> dict: addr += "_" + str(entry[CONF_COMMAND_OFF]) inx = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) addr += "_" + str(inx) - if addr in addresses: - err = ( - f"Modbus {component}/{name} address {addr} is duplicate, second" - " entry not loaded!" - ) - _LOGGER.warning(err) + entry_addrs: set[str] = set() + entry_addrs.add(addr) + + if CONF_TARGET_TEMP in entry: + a = str(entry[CONF_TARGET_TEMP]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_HVAC_MODE_REGISTER in entry: + a = str(entry[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_FAN_MODE_REGISTER in entry: + a = str(entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + + dup_addrs = entry_addrs.intersection(addresses) + + if len(dup_addrs) > 0: + for addr in dup_addrs: + err = ( + f"Modbus {component}/{name} address {addr} is duplicate, second" + " entry not loaded!" + ) + _LOGGER.warning(err) errors.append(index) elif name in names: err = ( @@ -280,7 +303,7 @@ def duplicate_entity_validator(config: dict) -> dict: errors.append(index) else: names.add(name) - addresses.add(addr) + addresses.update(entry_addrs) for i in reversed(errors): del config[hub_index][conf_key][i] @@ -299,11 +322,11 @@ def duplicate_modbus_validator(config: list) -> list: else: host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}" if host in hosts: - err = f"Modbus {name}  contains duplicate host/port {host}, not loaded!" + err = f"Modbus {name} contains duplicate host/port {host}, not loaded!" _LOGGER.warning(err) errors.append(index) elif name in names: - err = f"Modbus {name}  is duplicate, second entry not loaded!" + err = f"Modbus {name} is duplicate, second entry not loaded!" _LOGGER.warning(err) errors.append(index) else: @@ -313,3 +336,20 @@ def duplicate_modbus_validator(config: list) -> list: for i in reversed(errors): del config[i] return config + + +def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict: + """Control modbus climate fan mode values for duplicates.""" + fan_modes: set[int] = set() + errors = [] + for key, value in config[CONF_FAN_MODE_VALUES].items(): + if value in fan_modes: + wrn = f"Modbus fan mode {key} has a duplicate value {value}, not loaded, values must be unique!" + _LOGGER.warning(wrn) + errors.append(key) + else: + fan_modes.add(value) + + for key in reversed(errors): + del config[CONF_FAN_MODE_VALUES][key] + return config diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 4b4ba00b4c6..325b68869e0 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -3,14 +3,34 @@ import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.climate.const import ( + ATTR_FAN_MODE, + ATTR_FAN_MODES, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, HVACMode, ) from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -183,7 +203,7 @@ async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: ], ) async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> None: - """Run configuration test for mode register.""" + """Run configuration test for HVAC mode register.""" state = hass.states.get(ENTITY_ID) assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] assert HVACMode.HEAT in state.attributes[ATTR_HVAC_MODES] @@ -193,6 +213,47 @@ async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> No assert HVACMode.FAN_ONLY in state.attributes[ATTR_HVAC_MODES] +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_OFF: 1, + CONF_FAN_MODE_AUTO: 2, + CONF_FAN_MODE_LOW: 3, + CONF_FAN_MODE_MEDIUM: 4, + CONF_FAN_MODE_HIGH: 5, + }, + }, + } + ], + }, + ], +) +async def test_config_fan_mode_register(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for Fan mode register.""" + state = hass.states.get(ENTITY_ID) + assert FAN_ON in state.attributes[ATTR_FAN_MODES] + assert FAN_OFF in state.attributes[ATTR_FAN_MODES] + assert FAN_AUTO in state.attributes[ATTR_FAN_MODES] + assert FAN_LOW in state.attributes[ATTR_FAN_MODES] + assert FAN_MEDIUM in state.attributes[ATTR_FAN_MODES] + assert FAN_HIGH in state.attributes[ATTR_FAN_MODES] + assert FAN_TOP not in state.attributes[ATTR_FAN_MODES] + assert FAN_MIDDLE not in state.attributes[ATTR_FAN_MODES] + assert FAN_DIFFUSE not in state.attributes[ATTR_FAN_MODES] + assert FAN_FOCUS not in state.attributes[ATTR_FAN_MODES] + + @pytest.mark.parametrize( "do_config", [ @@ -338,6 +399,96 @@ async def test_service_climate_update( assert hass.states.get(ENTITY_ID).state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_LOW, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_MEDIUM, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + }, + ] + }, + FAN_HIGH, + [0x02], + ), + ], +) +async def test_service_climate_fan_update( + hass: HomeAssistant, mock_modbus, mock_ha, result, register_words +) -> None: + """Run test for service homeassistant.update_entity.""" + mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).attributes[ATTR_FAN_MODE] == result + + @pytest.mark.parametrize( ("temperature", "result", "do_config"), [ @@ -529,10 +680,10 @@ async def test_service_climate_set_temperature( ), ], ) -async def test_service_set_mode( +async def test_service_set_hvac_mode( hass: HomeAssistant, hvac_mode, result, mock_modbus, mock_ha ) -> None: - """Test set mode.""" + """Test set HVAC mode.""" mock_modbus.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, @@ -545,6 +696,69 @@ async def test_service_set_mode( ) +@pytest.mark.parametrize( + ("fan_mode", "result", "do_config"), + [ + ( + FAN_OFF, + [0x02], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ( + FAN_ON, + [0x01], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ], +) +async def test_service_set_fan_mode( + hass: HomeAssistant, fan_mode, result, mock_modbus, mock_ha +) -> None: + """Test set Fan mode.""" + mock_modbus.read_holding_registers.return_value = ReadResult(result) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_fan_mode", + { + "entity_id": ENTITY_ID, + ATTR_FAN_MODE: fan_mode, + }, + blocking=True, + ) + + test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37} diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index e66115f24d9..df415807119 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -40,9 +40,19 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_WRITE_REGISTERS, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_CLIMATES, CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_COOL, + CONF_HVAC_MODE_HEAT, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, @@ -53,6 +63,7 @@ from homeassistant.components.modbus.const import ( CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, @@ -68,6 +79,7 @@ from homeassistant.components.modbus.const import ( ) from homeassistant.components.modbus.validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -361,6 +373,25 @@ async def test_duplicate_modbus_validator(do_config) -> None: assert len(do_config) == 1 +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 7, + CONF_FAN_MODE_OFF: 9, + CONF_FAN_MODE_HIGH: 9, + }, + } + ], +) +async def test_duplicate_fan_mode_validator(do_config) -> None: + """Test duplicate modbus validator.""" + duplicate_fan_mode_validator(do_config) + assert len(do_config[CONF_FAN_MODE_VALUES]) == 2 + + @pytest.mark.parametrize( "do_config", [ @@ -404,12 +435,170 @@ async def test_duplicate_modbus_validator(do_config) -> None: ], } ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], ], ) async def test_duplicate_entity_validator(do_config) -> None: """Test duplicate entity validator.""" duplicate_entity_validator(do_config) - assert len(do_config[0][CONF_SENSORS]) == 1 + if CONF_SENSORS in do_config[0]: + assert len(do_config[0][CONF_SENSORS]) == 1 + elif CONF_CLIMATES in do_config[0]: + assert len(do_config[0][CONF_CLIMATES]) == 1 + + +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 119, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 99, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 117, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 121, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + ], +) +async def test_duplicate_entity_validator_with_climate(do_config) -> None: + """Test duplicate entity validator.""" + duplicate_entity_validator(do_config) + assert len(do_config[0][CONF_CLIMATES]) == 1 @pytest.mark.parametrize( From f420dcbedc6a11de4c89fdbe6dfcdc8e003e34ed Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 7 Dec 2023 09:18:34 +0100 Subject: [PATCH 555/643] =?UTF-8?q?Bump=20M=C3=A9t=C3=A9o-France=20to=201.?= =?UTF-8?q?3.0=20(#105170)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/meteo_france/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 3b6bb9c3518..567788ec479 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteo_france", "iot_class": "cloud_polling", "loggers": ["meteofrance_api"], - "requirements": ["meteofrance-api==1.2.0"] + "requirements": ["meteofrance-api==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c18b222251f..702da75f928 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1244,7 +1244,7 @@ messagebird==1.2.0 meteoalertapi==0.3.0 # homeassistant.components.meteo_france -meteofrance-api==1.2.0 +meteofrance-api==1.3.0 # homeassistant.components.mfi mficlient==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bdba3f3629d..425cd2cb382 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -968,7 +968,7 @@ medcom-ble==0.1.1 melnor-bluetooth==0.0.25 # homeassistant.components.meteo_france -meteofrance-api==1.2.0 +meteofrance-api==1.3.0 # homeassistant.components.mfi mficlient==0.3.0 From 2a8a60b3815639c377ccf821741315d23024f081 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 7 Dec 2023 09:19:38 +0100 Subject: [PATCH 556/643] Improve LIDL christmas light detection in deCONZ (#105155) --- homeassistant/components/deconz/light.py | 6 +++--- tests/components/deconz/test_light.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index dc2ed04b4ed..044c9bf203b 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -67,7 +67,7 @@ DECONZ_TO_COLOR_MODE = { LightColorMode.XY: ColorMode.XY, } -TS0601_EFFECTS = [ +XMAS_LIGHT_EFFECTS = [ "carnival", "collide", "fading", @@ -200,8 +200,8 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): if device.effect is not None: self._attr_supported_features |= LightEntityFeature.EFFECT self._attr_effect_list = [EFFECT_COLORLOOP] - if device.model_id == "TS0601": - self._attr_effect_list += TS0601_EFFECTS + if device.model_id in ("HG06467", "TS0601"): + self._attr_effect_list = XMAS_LIGHT_EFFECTS @property def color_mode(self) -> str | None: diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 357371e4853..d38c65526c2 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -186,7 +186,6 @@ async def test_no_lights_or_groups( "state": STATE_ON, "attributes": { ATTR_EFFECT_LIST: [ - EFFECT_COLORLOOP, "carnival", "collide", "fading", From ebde8ccfe436e32bac07c336ad3c942c97415b60 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 7 Dec 2023 02:22:03 -0600 Subject: [PATCH 557/643] Expose todo entities to Assist by default (#105150) --- homeassistant/components/homeassistant/exposed_entities.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 16a7ee5009c..926ab5025f6 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -38,6 +38,7 @@ DEFAULT_EXPOSED_DOMAINS = { "scene", "script", "switch", + "todo", "vacuum", "water_heater", } From 3d5329755f2ff47f3eb1e7c5a489794e62780c57 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 7 Dec 2023 09:24:27 +0100 Subject: [PATCH 558/643] Add extra tests for HomeWizard for unsupported entity creation (#105149) --- tests/components/homewizard/test_number.py | 4 ++-- tests/components/homewizard/test_switch.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index ebd8d80ece2..08154614833 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -97,7 +97,7 @@ async def test_number_entities( ) -@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) +@pytest.mark.parametrize("device_fixture", ["HWE-P1", "HWE-WTR", "SDM230", "SDM630"]) async def test_entities_not_created_for_device(hass: HomeAssistant) -> None: - """Does not load button when device has no support for it.""" + """Does not load number when device has no support for it.""" assert not hass.states.get("number.device_status_light_brightness") diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 2f6e777a3a8..61ca34fab7a 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -29,6 +29,13 @@ pytestmark = [ @pytest.mark.parametrize( ("device_fixture", "entity_ids"), [ + ( + "HWE-P1", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), ( "HWE-WTR", [ From 316776742f83290299827064b6c36223161d60c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Dec 2023 09:27:48 +0100 Subject: [PATCH 559/643] Bump actions/setup-python from 4.8.0 to 5.0.0 (#105193) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 6 +++--- .github/workflows/ci.yaml | 24 ++++++++++++------------ .github/workflows/translations.yml | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 618a9a08d1f..7c3a42aaaa1 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -124,7 +124,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4da01579cbb..2255b3f145c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -225,7 +225,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -269,7 +269,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -309,7 +309,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -348,7 +348,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -443,7 +443,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -511,7 +511,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -543,7 +543,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -576,7 +576,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -702,7 +702,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -854,7 +854,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -978,7 +978,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 42d7ea1dd4f..c8e25cc83ea 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.8.0 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} From e40f486968e75031b6ae5c36fe178e6e4fb4ae7e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Thu, 7 Dec 2023 09:33:33 +0100 Subject: [PATCH 560/643] Disable scenarios (scenes) for local API in Overkiz (#105153) --- homeassistant/components/overkiz/__init__.py | 20 ++++++++----------- homeassistant/components/overkiz/strings.json | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index ebc3f96a7f5..03a81f67308 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -1,10 +1,8 @@ """The Overkiz (by Somfy) integration.""" from __future__ import annotations -import asyncio from collections import defaultdict from dataclasses import dataclass -from typing import cast from aiohttp import ClientError from pyoverkiz.client import OverkizClient @@ -16,7 +14,7 @@ from pyoverkiz.exceptions import ( NotSuchTokenException, TooManyRequestsException, ) -from pyoverkiz.models import Device, OverkizServer, Scenario, Setup +from pyoverkiz.models import Device, OverkizServer, Scenario from pyoverkiz.utils import generate_local_server from homeassistant.config_entries import ConfigEntry @@ -82,13 +80,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await client.login() + setup = await client.get_setup() - setup, scenarios = await asyncio.gather( - *[ - client.get_setup(), - client.get_scenarios(), - ] - ) + # Local API does expose scenarios, but they are not functional. + # Tracked in https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/21 + if api_type == APIType.CLOUD: + scenarios = await client.get_scenarios() + else: + scenarios = [] except (BadCredentialsException, NotSuchTokenException) as exception: raise ConfigEntryAuthFailed("Invalid authentication") from exception except TooManyRequestsException as exception: @@ -98,9 +97,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except MaintenanceException as exception: raise ConfigEntryNotReady("Server is down for maintenance") from exception - setup = cast(Setup, setup) - scenarios = cast(list[Scenario], scenarios) - coordinator = OverkizDataUpdateCoordinator( hass, LOGGER, diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 2a549f1c24d..a756df4d0d6 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -9,7 +9,7 @@ } }, "local_or_cloud": { - "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices are not supported in local API.", + "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices and scenarios are not supported in local API.", "data": { "api_type": "API type" } From f48e94887180e7e550936951253329668a9bb0e9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 7 Dec 2023 09:35:22 +0100 Subject: [PATCH 561/643] Correct smtp error message string (#105148) --- homeassistant/components/smtp/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index e8347ef1a89..37250fa6447 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -7,7 +7,7 @@ }, "exceptions": { "remote_path_not_allowed": { - "message": "Cannot send email with attachment '{file_name}' form directory '{file_path}' which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." + "message": "Cannot send email with attachment \"{file_name}\" from directory \"{file_path}\" which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." } } } From f8c216a5ea28cbf625f812c04fde9f7e89f0aaeb Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 7 Dec 2023 09:37:20 +0100 Subject: [PATCH 562/643] Use brightness scaling util in HomeWizard Number entity (#105147) --- homeassistant/components/homewizard/number.py | 7 +++++-- tests/components/homewizard/snapshots/test_number.ambr | 2 +- tests/components/homewizard/test_number.py | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 58e0b02a06c..ced870d7072 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness from .const import DOMAIN from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -45,7 +46,9 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: """Set a new value.""" - await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) + await self.coordinator.api.state_set( + brightness=value_to_brightness((0, 100), value) + ) await self.coordinator.async_refresh() @property @@ -61,4 +64,4 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): or (brightness := self.coordinator.data.state.brightness) is None ): return None - return round(brightness * (100 / 255)) + return brightness_to_value((0, 100), brightness) diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 436abc70ac1..5c7e71ea9ac 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -14,7 +14,7 @@ 'entity_id': 'number.device_status_light_brightness', 'last_changed': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_number_entities[HWE-SKT].1 diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 08154614833..a54f98899c6 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -41,7 +41,7 @@ async def test_number_entities( assert snapshot == device_entry # Test unknown handling - assert state.state == "100" + assert state.state == "100.0" mock_homewizardenergy.state.return_value.brightness = None @@ -64,7 +64,7 @@ async def test_number_entities( ) assert len(mock_homewizardenergy.state_set.mock_calls) == 1 - mock_homewizardenergy.state_set.assert_called_with(brightness=127) + mock_homewizardenergy.state_set.assert_called_with(brightness=129) mock_homewizardenergy.state_set.side_effect = RequestError with pytest.raises( From 0728106c98c291a5e5f2f3dcecc17aebe083f97f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 7 Dec 2023 09:50:21 +0100 Subject: [PATCH 563/643] Increase ping update interval to 30 seconds (#105199) --- homeassistant/components/ping/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py index 5fe9d692bc3..f6bda9693b8 100644 --- a/homeassistant/components/ping/coordinator.py +++ b/homeassistant/components/ping/coordinator.py @@ -40,7 +40,7 @@ class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): hass, _LOGGER, name=f"Ping {ping.ip_address}", - update_interval=timedelta(seconds=15), + update_interval=timedelta(seconds=30), ) async def _async_update_data(self) -> PingResult: From d61814a1edac1ec6cfdd54eb46967ca3041bf916 Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Thu, 7 Dec 2023 09:57:01 +0100 Subject: [PATCH 564/643] Add custom services for renson fans (#94497) Co-authored-by: Robert Resch --- homeassistant/components/renson/const.py | 1 + homeassistant/components/renson/fan.py | 88 +++++++++++++ homeassistant/components/renson/services.yaml | 117 ++++++++++++++++++ homeassistant/components/renson/strings.json | 81 ++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 homeassistant/components/renson/services.yaml diff --git a/homeassistant/components/renson/const.py b/homeassistant/components/renson/const.py index 840e1ce428a..53bbd90c4b7 100644 --- a/homeassistant/components/renson/const.py +++ b/homeassistant/components/renson/const.py @@ -1,3 +1,4 @@ """Constants for the Renson integration.""" + DOMAIN = "renson" diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 6bceca92db0..a60adccade5 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -7,10 +7,13 @@ from typing import Any from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType from renson_endura_delta.renson import Level, RensonVentilation +import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -41,6 +44,33 @@ SPEED_MAPPING = { Level.LEVEL4.value: 4, } +SET_TIMER_LEVEL_SCHEMA = { + vol.Required("timer_level"): vol.In( + ["level1", "level2", "level3", "level4", "holiday", "breeze"] + ), + vol.Required("minutes"): cv.positive_int, +} + +SET_BREEZE_SCHEMA = { + vol.Required("breeze_level"): vol.In(["level1", "level2", "level3", "level4"]), + vol.Required("temperature"): cv.positive_int, + vol.Required("activate"): bool, +} + +SET_POLLUTION_SETTINGS_SCHEMA = { + vol.Required("day_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Required("night_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Optional("humidity_control", default=True): bool, + vol.Optional("airquality_control", default=True): bool, + vol.Optional("co2_control", default=True): bool, + vol.Optional("co2_threshold", default=600): cv.positive_int, + vol.Optional("co2_hysteresis", default=100): cv.positive_int, +} + SPEED_RANGE: tuple[float, float] = (1, 4) @@ -59,6 +89,24 @@ async def async_setup_entry( async_add_entities([RensonFan(api, coordinator)]) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + "set_timer_level", + SET_TIMER_LEVEL_SCHEMA, + "set_timer_level", + ) + + platform.async_register_entity_service( + "set_breeze", SET_BREEZE_SCHEMA, "set_breeze" + ) + + platform.async_register_entity_service( + "set_pollution_settings", + SET_POLLUTION_SETTINGS_SCHEMA, + "set_pollution_settings", + ) + class RensonFan(RensonEntity, FanEntity): """Representation of the Renson fan platform.""" @@ -116,3 +164,43 @@ class RensonFan(RensonEntity, FanEntity): await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) await self.coordinator.async_request_refresh() + + async def set_timer_level(self, timer_level: str, minutes: int) -> None: + """Set timer level.""" + level = Level[str(timer_level).upper()] + + await self.hass.async_add_executor_job(self.api.set_timer_level, level, minutes) + + async def set_breeze( + self, breeze_level: str, temperature: int, activate: bool + ) -> None: + """Configure breeze feature.""" + level = Level[str(breeze_level).upper()] + + await self.hass.async_add_executor_job( + self.api.set_breeze, level, temperature, activate + ) + + async def set_pollution_settings( + self, + day_pollution_level: str, + night_pollution_level: str, + humidity_control: bool, + airquality_control: bool, + co2_control: str, + co2_threshold: int, + co2_hysteresis: int, + ) -> None: + """Configure pollutions settings.""" + day = Level[str(day_pollution_level).upper()] + night = Level[str(night_pollution_level).upper()] + + await self.api.set_pollution( + day, + night, + humidity_control, + airquality_control, + co2_control, + co2_threshold, + co2_hysteresis, + ) diff --git a/homeassistant/components/renson/services.yaml b/homeassistant/components/renson/services.yaml new file mode 100644 index 00000000000..ad79af8649e --- /dev/null +++ b/homeassistant/components/renson/services.yaml @@ -0,0 +1,117 @@ +set_timer_level: + target: + entity: + integration: renson + domain: fan + fields: + timer_level: + required: true + default: "level1" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + - "holiday" + - "breeze" + minutes: + required: true + default: 0 + selector: + number: + min: 0 + max: 1440 + step: 10 + unit_of_measurement: "min" + mode: slider + +set_breeze: + target: + entity: + integration: renson + domain: fan + fields: + breeze_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + temperature: + default: 18 + selector: + number: + min: 15 + max: 35 + step: 1 + unit_of_measurement: "°C" + mode: slider + activate: + required: true + default: false + selector: + boolean: + +set_pollution_settings: + target: + entity: + integration: renson + domain: fan + fields: + day_pollution_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + night_pollution_level: + default: "level2" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + humidity_control: + default: true + selector: + boolean: + airquality_control: + default: true + selector: + boolean: + co2_control: + default: true + selector: + boolean: + co2_threshold: + default: 600 + selector: + number: + min: 400 + max: 2000 + step: 50 + unit_of_measurement: "ppm" + mode: slider + co2_hysteresis: + default: 100 + selector: + number: + min: 50 + max: 400 + step: 50 + unit_of_measurement: "ppm" + mode: slider diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 8aa7c6244ea..a826b5a3dd3 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -162,5 +162,86 @@ "name": "Bypass level" } } + }, + "selector": { + "level_setting": { + "options": { + "off": "[%key:common::state::off%]", + "level1": "[%key:component::renson::entity::sensor::ventilation_level::state::level1%]", + "level2": "[%key:component::renson::entity::sensor::ventilation_level::state::level2%]", + "level3": "[%key:component::renson::entity::sensor::ventilation_level::state::level3%]", + "level4": "[%key:component::renson::entity::sensor::ventilation_level::state::level4%]", + "breeze": "[%key:component::renson::entity::sensor::ventilation_level::state::breeze%]", + "holiday": "[%key:component::renson::entity::sensor::ventilation_level::state::holiday%]" + } + } + }, + "services": { + "set_timer_level": { + "name": "Set timer", + "description": "Set the ventilation timer", + "fields": { + "timer_level": { + "name": "Level", + "description": "Level setting" + }, + "minutes": { + "name": "Time", + "description": "Time of the timer (0 will disable the timer)" + } + } + }, + "set_breeze": { + "name": "Set breeze", + "description": "Set the breeze function of the ventilation system", + "fields": { + "breeze_level": { + "name": "[%key:component::renson::services::set_timer_level::fields::timer_level::name%]", + "description": "Ventilation level when breeze function is activated" + }, + "temperature": { + "name": "Temperature", + "description": "Temperature when the breeze function should be activated" + }, + "activate": { + "name": "Activate", + "description": "Activate or disable the breeze feature" + } + } + }, + "set_pollution_settings": { + "name": "Set pollution settings", + "description": "Set all the pollution settings of the ventilation system", + "fields": { + "day_pollution_level": { + "name": "Day pollution Level", + "description": "Ventilation level when pollution is detected in the day" + }, + "night_pollution_level": { + "name": "Night pollution Level", + "description": "Ventilation level when pollution is detected in the night" + }, + "humidity_control": { + "name": "Enable humidity control", + "description": "Activate or disable the humidity control" + }, + "airquality_control": { + "name": "Enable air quality control", + "description": "Activate or disable the air quality control" + }, + "co2_control": { + "name": "Enable CO2 control", + "description": "Activate or disable the CO2 control" + }, + "co2_threshold": { + "name": "CO2 threshold", + "description": "Sets the CO2 pollution threshold level in ppm" + }, + "co2_hysteresis": { + "name": "CO2 hysteresis", + "description": "Sets the CO2 pollution threshold hysteresis level in ppm" + } + } + } } } From de758fd7b39742e3b2bf35f6ae61dbe3f6808f66 Mon Sep 17 00:00:00 2001 From: dewdrop <81049573+dewdropawoo@users.noreply.github.com> Date: Thu, 7 Dec 2023 01:39:54 -0800 Subject: [PATCH 565/643] Add Seattle City Light virtual integration via Opower (#104997) --- homeassistant/components/scl/__init__.py | 1 + homeassistant/components/scl/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/scl/__init__.py create mode 100644 homeassistant/components/scl/manifest.json diff --git a/homeassistant/components/scl/__init__.py b/homeassistant/components/scl/__init__.py new file mode 100644 index 00000000000..ae3b8d58f5e --- /dev/null +++ b/homeassistant/components/scl/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Seattle City Light (SCL).""" diff --git a/homeassistant/components/scl/manifest.json b/homeassistant/components/scl/manifest.json new file mode 100644 index 00000000000..11fce2c4b47 --- /dev/null +++ b/homeassistant/components/scl/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "scl", + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d99487c2a60..36aae4f799b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4953,6 +4953,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "scl": { + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" + }, "scrape": { "name": "Scrape", "integration_type": "hub", From 334673154c78c46d2548f58762cb1a2c969eb5d1 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Thu, 7 Dec 2023 10:00:26 +0000 Subject: [PATCH 566/643] Fix bug in roon incremental volume control. (#105201) --- homeassistant/components/roon/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index d6128d26723..dda323c2c2a 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -373,14 +373,14 @@ class RoonDevice(MediaPlayerEntity): def volume_up(self) -> None: """Send new volume_level to device.""" if self._volume_incremental: - self._server.roonapi.change_volume_raw(self.output_id, 1, "relative_step") + self._server.roonapi.change_volume_raw(self.output_id, 1, "relative") else: self._server.roonapi.change_volume_percent(self.output_id, 3) def volume_down(self) -> None: """Send new volume_level to device.""" if self._volume_incremental: - self._server.roonapi.change_volume_raw(self.output_id, -1, "relative_step") + self._server.roonapi.change_volume_raw(self.output_id, -1, "relative") else: self._server.roonapi.change_volume_percent(self.output_id, -3) From 84329844fd0e2c3d680f0431cdfb790f89d9c574 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Dec 2023 13:25:23 +0100 Subject: [PATCH 567/643] Disable config flow progress in peco config flow (#105222) --- homeassistant/components/peco/config_flow.py | 18 ++---------- tests/components/peco/test_config_flow.py | 30 -------------------- 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/peco/config_flow.py b/homeassistant/components/peco/config_flow.py index 261cdb031bf..144495ec066 100644 --- a/homeassistant/components/peco/config_flow.py +++ b/homeassistant/components/peco/config_flow.py @@ -33,7 +33,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - meter_verification: bool = False meter_data: dict[str, str] = {} meter_error: dict[str, str] = {} @@ -53,17 +52,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except HttpError: self.meter_error = {"phone_number": "http_error", "type": "error"} - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - if self.meter_verification is True: - return self.async_show_progress_done(next_step_id="finish_smart_meter") - if user_input is None: return self.async_show_form( step_id="user", @@ -86,20 +78,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(f"{county}-{phone_number}") self._abort_if_unique_id_configured() - self.meter_verification = True - if self.meter_error is not None: # Clear any previous errors, since the user may have corrected them self.meter_error = {} - self.hass.async_create_task(self._verify_meter(phone_number)) + await self._verify_meter(phone_number) self.meter_data = user_input - return self.async_show_progress( - step_id="user", - progress_action="verifying_meter", - ) + return await self.async_step_finish_smart_meter() async def async_step_finish_smart_meter( self, user_input: dict[str, Any] | None = None @@ -107,7 +94,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the finish smart meter step.""" if "phone_number" in self.meter_error: if self.meter_error["type"] == "error": - self.meter_verification = False return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, diff --git a/tests/components/peco/test_config_flow.py b/tests/components/peco/test_config_flow.py index ca6759baeff..9ce87d707ff 100644 --- a/tests/components/peco/test_config_flow.py +++ b/tests/components/peco/test_config_flow.py @@ -78,12 +78,6 @@ async def test_meter_value_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "user" - assert result["progress_action"] == "verifying_meter" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"phone_number": "invalid_phone_number"} @@ -107,12 +101,6 @@ async def test_incompatible_meter_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "user" - assert result["progress_action"] == "verifying_meter" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.ABORT assert result["reason"] == "incompatible_meter" @@ -135,12 +123,6 @@ async def test_unresponsive_meter_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "user" - assert result["progress_action"] == "verifying_meter" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"phone_number": "unresponsive_meter"} @@ -164,12 +146,6 @@ async def test_meter_http_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "user" - assert result["progress_action"] == "verifying_meter" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"phone_number": "http_error"} @@ -193,12 +169,6 @@ async def test_smart_meter(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "user" - assert result["progress_action"] == "verifying_meter" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Philadelphia - 1234567890" assert result["data"]["phone_number"] == "1234567890" From d80547519af76649cfa6173c215ac1dd31d07966 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 7 Dec 2023 14:57:08 +0100 Subject: [PATCH 568/643] Bump easyenergy lib to v2.1.0 (#105224) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 0e57133a89a..6f57ea6ed5f 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==2.0.0"] + "requirements": ["easyenergy==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 702da75f928..58d4042cbb4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -726,7 +726,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==2.0.0 +easyenergy==2.1.0 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 425cd2cb382..55ba9d169e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -592,7 +592,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==2.0.0 +easyenergy==2.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 From d86abf214b214f7c80770f8879b35fa644dda283 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 7 Dec 2023 15:22:30 +0100 Subject: [PATCH 569/643] Bump energyzero lib to v2.1.0 (#105228) --- homeassistant/components/energyzero/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 7b1588eeb54..025f929a4f6 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==2.0.0"] + "requirements": ["energyzero==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 58d4042cbb4..e4fcb0cb396 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -759,7 +759,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==2.0.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 55ba9d169e6..e46bcbbe862 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -613,7 +613,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==2.0.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 From 83a1ca5e8373f44f084ba3d7f0b8531513209ce8 Mon Sep 17 00:00:00 2001 From: haimn Date: Thu, 7 Dec 2023 19:03:07 +0200 Subject: [PATCH 570/643] fix supportedFanOscillationModes is null (#105205) * fix supportedFanOscillationModes is null * set default supported_swings to None * return None if no fan oscillation modes listed --- homeassistant/components/smartthings/climate.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 16558d2c795..b97ca06a471 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -497,14 +497,16 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Return the unit of measurement.""" return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit] - def _determine_swing_modes(self) -> list[str]: + def _determine_swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" + supported_swings = None supported_modes = self._device.status.attributes[ Attribute.supported_fan_oscillation_modes ][0] - supported_swings = [ - FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes - ] + if supported_modes is not None: + supported_swings = [ + FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes + ] return supported_swings async def async_set_swing_mode(self, swing_mode: str) -> None: From dd902bc9566a8c44e0ccf5fc016ff79528c0b5d2 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 7 Dec 2023 19:47:14 +0100 Subject: [PATCH 571/643] Explicit check for None in Discovergy entity if condition (#105248) Fix checking for None in Discovergy --- homeassistant/components/discovergy/sensor.py | 1 + tests/components/discovergy/const.py | 2 +- tests/components/discovergy/snapshots/test_diagnostics.ambr | 2 +- tests/components/discovergy/snapshots/test_sensor.ambr | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index ed878fbb82e..9648492c2e4 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -183,6 +183,7 @@ async def async_setup_entry( for description in sensors for value_key in {description.key, *description.alternative_keys} if description.value_fn(coordinator.data, value_key, description.scale) + is not None ) async_add_entities(entities) diff --git a/tests/components/discovergy/const.py b/tests/components/discovergy/const.py index 6c5428741af..5e596d7970f 100644 --- a/tests/components/discovergy/const.py +++ b/tests/components/discovergy/const.py @@ -67,7 +67,7 @@ LAST_READING = Reading( "energyOut": 55048723044000.0, "energyOut1": 0.0, "energyOut2": 0.0, - "power": 531750.0, + "power": 0.0, "power1": 142680.0, "power2": 138010.0, "power3": 251060.0, diff --git a/tests/components/discovergy/snapshots/test_diagnostics.ambr b/tests/components/discovergy/snapshots/test_diagnostics.ambr index 2a7dd6903af..e8d4eab1909 100644 --- a/tests/components/discovergy/snapshots/test_diagnostics.ambr +++ b/tests/components/discovergy/snapshots/test_diagnostics.ambr @@ -61,7 +61,7 @@ 'energyOut': 55048723044000.0, 'energyOut1': 0.0, 'energyOut2': 0.0, - 'power': 531750.0, + 'power': 0.0, 'power1': 142680.0, 'power2': 138010.0, 'power3': 251060.0, diff --git a/tests/components/discovergy/snapshots/test_sensor.ambr b/tests/components/discovergy/snapshots/test_sensor.ambr index 981d1119a93..2473af5012a 100644 --- a/tests/components/discovergy/snapshots/test_sensor.ambr +++ b/tests/components/discovergy/snapshots/test_sensor.ambr @@ -132,7 +132,7 @@ 'entity_id': 'sensor.electricity_teststrasse_1_total_power', 'last_changed': , 'last_updated': , - 'state': '531.75', + 'state': '0.0', }) # --- # name: test_sensor[gas last transmitted] From 2daa94b600df15758a1912c6fafa24f6f935315f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 7 Dec 2023 20:04:39 +0100 Subject: [PATCH 572/643] Fix check_date service in workday (#105241) * Fix check_date service in workday * Add test --- .../components/workday/binary_sensor.py | 21 ++++++++++++------- .../components/workday/test_binary_sensor.py | 12 +++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 9cc96db7a57..2d1030c6b92 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -209,21 +209,26 @@ class IsWorkdaySensor(BinarySensorEntity): async def async_update(self) -> None: """Get date and look whether it is a holiday.""" + self._attr_is_on = self.date_is_workday(dt_util.now()) + + async def check_date(self, check_date: date) -> ServiceResponse: + """Service to check if date is workday or not.""" + return {"workday": self.date_is_workday(check_date)} + + def date_is_workday(self, check_date: date) -> bool: + """Check if date is workday.""" # Default is no workday - self._attr_is_on = False + is_workday = False # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = dt_util.now() + timedelta(days=self._days_offset) + adjusted_date = check_date + timedelta(days=self._days_offset) day = adjusted_date.isoweekday() - 1 day_of_week = ALLOWED_DAYS[day] if self.is_include(day_of_week, adjusted_date): - self._attr_is_on = True + is_workday = True if self.is_exclude(day_of_week, adjusted_date): - self._attr_is_on = False + is_workday = False - async def check_date(self, check_date: date) -> ServiceResponse: - """Check if date is workday or not.""" - holiday_date = check_date in self._obj_holidays - return {"workday": not holiday_date} + return is_workday diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 7457d2e0ada..a359d83d87d 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -316,6 +316,18 @@ async def test_check_date_service( ) assert response == {"binary_sensor.workday_sensor": {"workday": True}} + response = await hass.services.async_call( + DOMAIN, + SERVICE_CHECK_DATE, + { + "entity_id": "binary_sensor.workday_sensor", + "check_date": date(2022, 12, 17), # Saturday (no workday) + }, + blocking=True, + return_response=True, + ) + assert response == {"binary_sensor.workday_sensor": {"workday": False}} + async def test_language_difference_english_language( hass: HomeAssistant, From 6666b796f2d08dc8409d954104d7c3350dd37742 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 7 Dec 2023 21:18:10 +0100 Subject: [PATCH 573/643] Migrate auth tests to use freezegun (#105243) --- tests/auth/test_init.py | 5 +---- tests/components/auth/test_init.py | 26 ++++++++++++-------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index ef7beab488b..9e9b48a07f6 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -894,10 +894,7 @@ async def test_auth_module_expired_session(mock_hass) -> None: assert step["type"] == data_entry_flow.FlowResultType.FORM assert step["step_id"] == "mfa" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() + MFA_SESSION_EXPIRATION, - ): + with freeze_time(dt_util.utcnow() + MFA_SESSION_EXPIRATION): step = await manager.login_flow.async_configure( step["flow_id"], {"pin": "test-pin"} ) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index a33ca702bcf..4088b1819fa 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -4,6 +4,7 @@ from http import HTTPStatus import logging from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.auth import InvalidAuthError @@ -167,28 +168,25 @@ async def test_auth_code_checks_local_only_user( assert error["error"] == "access_denied" -def test_auth_code_store_expiration(mock_credential) -> None: +def test_auth_code_store_expiration( + mock_credential, freezer: FrozenDateTimeFactory +) -> None: """Test that the auth code store will not return expired tokens.""" store, retrieve = auth._create_auth_code_store() client_id = "bla" now = utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", return_value=now + timedelta(minutes=10) - ): - assert retrieve(client_id, code) is None + freezer.move_to(now + timedelta(minutes=10)) + assert retrieve(client_id, code) is None - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", - return_value=now + timedelta(minutes=9, seconds=59), - ): - assert retrieve(client_id, code) == mock_credential + freezer.move_to(now + timedelta(minutes=9, seconds=59)) + assert retrieve(client_id, code) == mock_credential def test_auth_code_store_requires_credentials(mock_credential) -> None: From 4c4ad9404fb427fa2be8d907e5e93c8686c2f9a7 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 7 Dec 2023 14:28:04 -0600 Subject: [PATCH 574/643] Don't return TTS URL in Assist pipeline (#105164) * Don't return TTS URL * Add test for empty queue --- .../components/assist_pipeline/pipeline.py | 10 +-- tests/components/assist_pipeline/test_init.py | 64 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 4f2a9a8d99b..ed9029d1c2c 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -9,7 +9,7 @@ from dataclasses import asdict, dataclass, field from enum import StrEnum import logging from pathlib import Path -from queue import Queue +from queue import Empty, Queue from threading import Thread import time from typing import TYPE_CHECKING, Any, Final, cast @@ -1010,8 +1010,8 @@ class PipelineRun: self.tts_engine = engine self.tts_options = tts_options - async def text_to_speech(self, tts_input: str) -> str: - """Run text-to-speech portion of pipeline. Returns URL of TTS audio.""" + async def text_to_speech(self, tts_input: str) -> None: + """Run text-to-speech portion of pipeline.""" self.process_event( PipelineEvent( PipelineEventType.TTS_START, @@ -1058,8 +1058,6 @@ class PipelineRun: PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) ) - return tts_media.url - def _capture_chunk(self, audio_bytes: bytes | None) -> None: """Forward audio chunk to various capturing mechanisms.""" if self.debug_recording_queue is not None: @@ -1246,6 +1244,8 @@ def _pipeline_debug_recording_thread_proc( # Chunk of 16-bit mono audio at 16Khz if wav_writer is not None: wav_writer.writeframes(message) + except Empty: + pass # occurs when pipeline has unexpected error except Exception: # pylint: disable=broad-exception-caught _LOGGER.exception("Unexpected error in debug recording thread") finally: diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 24a4a92536d..882d3a80fb3 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1,4 +1,5 @@ """Test Voice Assistant init.""" +import asyncio from dataclasses import asdict import itertools as it from pathlib import Path @@ -569,6 +570,69 @@ async def test_pipeline_saved_audio_write_error( ) +async def test_pipeline_saved_audio_empty_queue( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_supporting_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that saved audio thread closes WAV file even if there's an empty queue.""" + with tempfile.TemporaryDirectory() as temp_dir_str: + # Enable audio recording to temporary directory + temp_dir = Path(temp_dir_str) + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_DEBUG_RECORDING_DIR: temp_dir_str}}, + ) + + def event_callback(event: assist_pipeline.PipelineEvent): + if event.type == "run-end": + # Verify WAV file exists, but contains no data + pipeline_dirs = list(temp_dir.iterdir()) + run_dirs = list(pipeline_dirs[0].iterdir()) + wav_path = next(run_dirs[0].iterdir()) + with wave.open(str(wav_path), "rb") as wav_file: + assert wav_file.getnframes() == 0 + + async def audio_data(): + # Force timeout in _pipeline_debug_recording_thread_proc + await asyncio.sleep(1) + yield b"not used" + + # Wrap original function to time out immediately + _pipeline_debug_recording_thread_proc = ( + assist_pipeline.pipeline._pipeline_debug_recording_thread_proc + ) + + def proc_wrapper(run_recording_dir, queue): + _pipeline_debug_recording_thread_proc( + run_recording_dir, queue, message_timeout=0 + ) + + with patch( + "homeassistant.components.assist_pipeline.pipeline._pipeline_debug_recording_thread_proc", + proc_wrapper, + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=event_callback, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.STT, + ) + + async def test_wake_word_detection_aborted( hass: HomeAssistant, mock_stt_provider: MockSttProvider, From 8a4b761c78106a241840b867e8f96d8a9fe149e6 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 7 Dec 2023 21:55:37 +0100 Subject: [PATCH 575/643] Use freezegun in cert_expiry tests (#105125) --- tests/components/cert_expiry/test_init.py | 6 ++++-- tests/components/cert_expiry/test_sensors.py | 20 +++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 6c1d593560e..00f8a34fb0c 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -2,6 +2,8 @@ from datetime import timedelta from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -73,8 +75,8 @@ async def test_update_unique_id(hass: HomeAssistant) -> None: assert entry.unique_id == f"{HOST}:{PORT}" -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_unload_config_entry(hass: HomeAssistant) -> None: """Test unloading a config entry.""" assert hass.state is CoreState.running diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 48421f5c41f..18a70fa9ab6 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -4,6 +4,8 @@ import socket import ssl from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import CoreState, HomeAssistant @@ -15,8 +17,8 @@ from .helpers import future_timestamp, static_datetime from tests.common import MockConfigEntry, async_fire_time_changed -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_async_setup_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_async_setup_entry(hass: HomeAssistant) -> None: """Test async_setup_entry.""" assert hass.state is CoreState.running @@ -82,7 +84,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -98,7 +100,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: assert state.attributes.get("is_valid") next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -126,7 +128,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -143,7 +145,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=socket.gaierror, ): @@ -155,7 +157,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -171,7 +173,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=72) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=ssl.SSLError("something bad"), ): @@ -186,7 +188,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=96) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=Exception() ): async_fire_time_changed(hass, utcnow() + timedelta(hours=96)) From c4d77877d21995e1747d649ae2cc0b2053e555cc Mon Sep 17 00:00:00 2001 From: On Freund Date: Thu, 7 Dec 2023 23:11:08 +0200 Subject: [PATCH 576/643] Fix update of uncategorized OurGroceries items (#105255) * Fix update of uncategorized OurGroceries items * Address code review comments --- homeassistant/components/ourgroceries/todo.py | 2 +- tests/components/ourgroceries/test_todo.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py index 8115066d0fb..5b8d19e5aa1 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -89,7 +89,7 @@ class OurGroceriesTodoListEntity( if item.summary: api_items = self.coordinator.data[self._list_id]["list"]["items"] category = next( - api_item["categoryId"] + api_item.get("categoryId") for api_item in api_items if api_item["id"] == item.uid ) diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py index 65bbff0e601..8686c52d79b 100644 --- a/tests/components/ourgroceries/test_todo.py +++ b/tests/components/ourgroceries/test_todo.py @@ -142,12 +142,20 @@ async def test_update_todo_item_status( @pytest.mark.parametrize( - ("items"), [[{"id": "12345", "name": "Soda", "categoryId": "test_category"}]] + ("items", "category"), + [ + ( + [{"id": "12345", "name": "Soda", "categoryId": "test_category"}], + "test_category", + ), + ([{"id": "12345", "name": "Uncategorized"}], None), + ], ) async def test_update_todo_item_summary( hass: HomeAssistant, setup_integration: None, ourgroceries: AsyncMock, + category: str | None, ) -> None: """Test for updating an item summary.""" @@ -171,7 +179,7 @@ async def test_update_todo_item_summary( ) assert ourgroceries.change_item_on_list args = ourgroceries.change_item_on_list.call_args - assert args.args == ("test_list", "12345", "test_category", "Milk") + assert args.args == ("test_list", "12345", category, "Milk") @pytest.mark.parametrize( From 00e87b9dff40015ac46d3a273ef3f83d99f95297 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Dec 2023 22:47:52 +0100 Subject: [PATCH 577/643] Migrate Gree to has entity name (#104739) * Migrate Gree to has entity name * Migrate Gree to has entity name --- homeassistant/components/gree/climate.py | 16 +++------ homeassistant/components/gree/entity.py | 8 +++-- homeassistant/components/gree/strings.json | 19 +++++++++++ homeassistant/components/gree/switch.py | 26 ++++++-------- .../gree/snapshots/test_climate.ambr | 4 +-- .../gree/snapshots/test_switch.ambr | 34 +++++++++---------- 6 files changed, 58 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index ba162173724..8d50cdf2aed 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -38,10 +38,8 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .bridge import DeviceDataUpdateCoordinator from .const import ( @@ -52,6 +50,7 @@ from .const import ( FAN_MEDIUM_LOW, TARGET_TEMPERATURE_STEP, ) +from .entity import GreeEntity _LOGGER = logging.getLogger(__name__) @@ -105,7 +104,7 @@ async def async_setup_entry( ) -class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateEntity): +class GreeClimateEntity(GreeEntity, ClimateEntity): """Representation of a Gree HVAC device.""" _attr_precision = PRECISION_WHOLE @@ -120,19 +119,12 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE _attr_preset_modes = PRESET_MODES _attr_fan_modes = [*FAN_MODES_REVERSE] _attr_swing_modes = SWING_MODES + _attr_name = None def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" super().__init__(coordinator) - self._attr_name = coordinator.device.device_info.name - mac = coordinator.device.device_info.mac - self._attr_unique_id = mac - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, mac)}, - identifiers={(DOMAIN, mac)}, - manufacturer="Gree", - name=self._attr_name, - ) + self._attr_unique_id = coordinator.device.device_info.mac units = self.coordinator.device.temperature_units if units == TemperatureUnits.C: self._attr_temperature_unit = UnitOfTemperature.CELSIUS diff --git a/homeassistant/components/gree/entity.py b/homeassistant/components/gree/entity.py index fd1b80ef90d..c965ad45721 100644 --- a/homeassistant/components/gree/entity.py +++ b/homeassistant/components/gree/entity.py @@ -9,13 +9,15 @@ from .const import DOMAIN class GreeEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Generic Gree entity (base class).""" - def __init__(self, coordinator: DeviceDataUpdateCoordinator, desc: str) -> None: + _attr_has_entity_name = True + + def __init__( + self, coordinator: DeviceDataUpdateCoordinator, desc: str | None = None + ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._desc = desc name = coordinator.device.device_info.name mac = coordinator.device.device_info.mac - self._attr_name = f"{name} {desc}" self._attr_unique_id = f"{mac}_{desc}" self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, mac)}, diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index ad8f0f41ae7..45911433b92 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -9,5 +9,24 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "switch": { + "light": { + "name": "Panel light" + }, + "quiet": { + "name": "Quiet" + }, + "fresh_air": { + "name": "Fresh air" + }, + "xfan": { + "name": "XFan" + }, + "health_mode": { + "name": "Health mode" + } + } } } diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 7916df18abc..3c1893f7735 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -33,10 +33,6 @@ class GreeRequiredKeysMixin: class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): """Describes Gree switch entity.""" - # GreeSwitch does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - def _set_light(device: Device, value: bool) -> None: """Typed helper to set device light property.""" @@ -66,33 +62,33 @@ def _set_anion(device: Device, value: bool) -> None: GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( GreeSwitchEntityDescription( icon="mdi:lightbulb", - name="Panel Light", - key="light", + key="Panel Light", + translation_key="light", get_value_fn=lambda d: d.light, set_value_fn=_set_light, ), GreeSwitchEntityDescription( - name="Quiet", - key="quiet", + key="Quiet", + translation_key="quiet", get_value_fn=lambda d: d.quiet, set_value_fn=_set_quiet, ), GreeSwitchEntityDescription( - name="Fresh Air", - key="fresh_air", + key="Fresh Air", + translation_key="fresh_air", get_value_fn=lambda d: d.fresh_air, set_value_fn=_set_fresh_air, ), GreeSwitchEntityDescription( - name="XFan", - key="xfan", + key="XFan", + translation_key="xfan", get_value_fn=lambda d: d.xfan, set_value_fn=_set_xfan, ), GreeSwitchEntityDescription( icon="mdi:pine-tree", - name="Health mode", - key="anion", + key="Health mode", + translation_key="health_mode", get_value_fn=lambda d: d.anion, set_value_fn=_set_anion, entity_registry_enabled_default=False, @@ -134,7 +130,7 @@ class GreeSwitch(GreeEntity, SwitchEntity): """Initialize the Gree device.""" self.entity_description = description - super().__init__(coordinator, description.name) + super().__init__(coordinator, description.key) @property def is_on(self) -> bool: diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 568b98daec1..e28582ca2e9 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -98,7 +98,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.fake_device_1', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -107,7 +107,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'fake-device-1', + 'original_name': None, 'platform': 'gree', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index d2b0a5fbf4e..eff96ba1bd3 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -4,7 +4,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Panel Light', + 'friendly_name': 'fake-device-1 Panel light', 'icon': 'mdi:lightbulb', }), 'context': , @@ -27,7 +27,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Fresh Air', + 'friendly_name': 'fake-device-1 Fresh air', }), 'context': , 'entity_id': 'switch.fake_device_1_fresh_air', @@ -74,7 +74,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_panel_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -83,11 +83,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:lightbulb', - 'original_name': 'fake-device-1 Panel Light', + 'original_name': 'Panel light', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'light', 'unique_id': 'aabbcc112233_Panel Light', 'unit_of_measurement': None, }), @@ -103,7 +103,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_quiet', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -112,11 +112,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Quiet', + 'original_name': 'Quiet', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'quiet', 'unique_id': 'aabbcc112233_Quiet', 'unit_of_measurement': None, }), @@ -132,7 +132,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_fresh_air', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -141,11 +141,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Fresh Air', + 'original_name': 'Fresh air', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fresh_air', 'unique_id': 'aabbcc112233_Fresh Air', 'unit_of_measurement': None, }), @@ -161,7 +161,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_xfan', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -170,11 +170,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 XFan', + 'original_name': 'XFan', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'xfan', 'unique_id': 'aabbcc112233_XFan', 'unit_of_measurement': None, }), @@ -190,7 +190,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_health_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -199,11 +199,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:pine-tree', - 'original_name': 'fake-device-1 Health mode', + 'original_name': 'Health mode', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'health_mode', 'unique_id': 'aabbcc112233_Health mode', 'unit_of_measurement': None, }), From d1aa690c2400f1e4046ac37ff18ec047d440eb1e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 7 Dec 2023 22:58:09 +0100 Subject: [PATCH 578/643] Migrate non-component tests to use freezegun/freezer (#105142) --- tests/helpers/test_condition.py | 135 +++++++------- tests/helpers/test_event.py | 316 ++++++++++++++++---------------- tests/helpers/test_script.py | 11 +- tests/helpers/test_sun.py | 10 +- tests/helpers/test_template.py | 8 +- tests/test_core.py | 3 +- 6 files changed, 247 insertions(+), 236 deletions(-) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 3b8217028cc..bcb6f4fa971 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -1137,7 +1138,7 @@ async def test_state_for(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -1163,7 +1164,7 @@ async def test_state_for_template(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -2235,7 +2236,7 @@ async def test_if_action_before_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2247,7 +2248,7 @@ async def test_if_action_before_sunrise_no_offset( # now = sunrise -> 'before sunrise' true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2259,7 +2260,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight -> 'before sunrise' true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2271,7 +2272,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2306,7 +2307,7 @@ async def test_if_action_after_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2318,7 +2319,7 @@ async def test_if_action_after_sunrise_no_offset( # now = sunrise + 1s -> 'after sunrise' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2330,7 +2331,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2342,7 +2343,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2381,7 +2382,7 @@ async def test_if_action_before_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s + 1h -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2393,7 +2394,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunrise + 1h -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2405,7 +2406,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2417,7 +2418,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2429,7 +2430,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2441,7 +2442,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2453,7 +2454,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2465,7 +2466,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2504,7 +2505,7 @@ async def test_if_action_before_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = local midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2516,7 +2517,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1s + 1h -> 'before sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2528,7 +2529,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1h -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2540,7 +2541,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2552,7 +2553,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight - 1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2564,7 +2565,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2576,7 +2577,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2588,7 +2589,7 @@ async def test_if_action_before_sunset_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2627,7 +2628,7 @@ async def test_if_action_after_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s + 1h -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2639,7 +2640,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunrise + 1h -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 58, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2651,7 +2652,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2663,7 +2664,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon - 1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 11, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2675,7 +2676,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 19, 1, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2687,7 +2688,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon - 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 18, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2699,7 +2700,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2711,7 +2712,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset + 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2723,7 +2724,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2735,7 +2736,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2774,7 +2775,7 @@ async def test_if_action_after_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunset - 1s + 1h -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2786,7 +2787,7 @@ async def test_if_action_after_sunset_with_offset( # now = sunset + 1h -> 'after sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2798,7 +2799,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight-1s -> 'after sunset' with offset +1h true now = datetime(2015, 9, 16, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2810,7 +2811,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2849,7 +2850,7 @@ async def test_if_action_after_and_before_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2865,7 +2866,7 @@ async def test_if_action_after_and_before_during( # now = sunset + 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2877,7 +2878,7 @@ async def test_if_action_after_and_before_during( # now = sunrise + 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2893,7 +2894,7 @@ async def test_if_action_after_and_before_during( # now = sunset - 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2909,7 +2910,7 @@ async def test_if_action_after_and_before_during( # now = 9AM local -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2952,7 +2953,7 @@ async def test_if_action_before_or_after_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2968,7 +2969,7 @@ async def test_if_action_before_or_after_during( # now = sunset + 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2984,7 +2985,7 @@ async def test_if_action_before_or_after_during( # now = sunrise + 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3000,7 +3001,7 @@ async def test_if_action_before_or_after_during( # now = sunset - 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3016,7 +3017,7 @@ async def test_if_action_before_or_after_during( # now = midnight + 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 7, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -3032,7 +3033,7 @@ async def test_if_action_before_or_after_during( # now = midnight - 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -3077,7 +3078,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 15, 21, 13, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3089,7 +3090,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'before sunrise' true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3101,7 +3102,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3113,7 +3114,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3154,7 +3155,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise -> 'after sunrise' true now = datetime(2015, 7, 24, 15, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3166,7 +3167,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'after sunrise' not true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3178,7 +3179,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3190,7 +3191,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3231,7 +3232,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset + 1s -> 'before sunset' not true now = datetime(2015, 7, 25, 11, 13, 34, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3243,7 +3244,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = sunset - 1h-> 'before sunset' true now = datetime(2015, 7, 25, 10, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3255,7 +3256,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3267,7 +3268,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3308,7 +3309,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset -> 'after sunset' true now = datetime(2015, 7, 25, 11, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3320,7 +3321,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = sunset - 1s -> 'after sunset' not true now = datetime(2015, 7, 25, 11, 13, 32, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3332,7 +3333,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight -> 'after sunset' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3344,7 +3345,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunset' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 00ad580693e..245354a09a0 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -923,7 +923,9 @@ async def test_track_template_error_can_recover( async def test_track_template_time_change( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test tracking template with time change.""" template_error = Template("{{ utcnow().minute % 2 == 0 }}", hass) @@ -935,17 +937,15 @@ async def test_track_template_time_change( start_time = dt_util.utcnow() + timedelta(hours=24) time_that_will_not_match_right_away = start_time.replace(minute=1, second=0) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_template(hass, template_error, error_callback) - await hass.async_block_till_done() - assert not calls + freezer.move_to(time_that_will_not_match_right_away) + unsub = async_track_template(hass, template_error, error_callback) + await hass.async_block_till_done() + assert not calls first_time = start_time.replace(minute=2, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=first_time): - async_fire_time_changed(hass, first_time) - await hass.async_block_till_done() + freezer.move_to(first_time) + async_fire_time_changed(hass, first_time) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0] == (None, None, None) @@ -3312,84 +3312,89 @@ async def test_track_template_with_time_default(hass: HomeAssistant) -> None: info.async_remove() -async def test_track_template_with_time_that_leaves_scope(hass: HomeAssistant) -> None: +async def test_track_template_with_time_that_leaves_scope( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test tracking template with time.""" now = dt_util.utcnow() test_time = datetime(now.year + 1, 5, 24, 11, 59, 1, 500000, tzinfo=dt_util.UTC) + freezer.move_to(test_time) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - hass.states.async_set("binary_sensor.washing_machine", "on") - specific_runs = [] - template_complex = Template( - """ - {% if states.binary_sensor.washing_machine.state == "on" %} - {{ now() }} - {% else %} - {{ states.binary_sensor.washing_machine.last_updated }} - {% endif %} - """, - hass, - ) + hass.states.async_set("binary_sensor.washing_machine", "on") + specific_runs = [] + template_complex = Template( + """ + {% if states.binary_sensor.washing_machine.state == "on" %} + {{ now() }} + {% else %} + {{ states.binary_sensor.washing_machine.last_updated }} + {% endif %} + """, + hass, + ) - def specific_run_callback( - event: EventType[EventStateChangedData] | None, - updates: list[TrackTemplateResult], - ) -> None: - specific_runs.append(updates.pop().result) + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: + specific_runs.append(updates.pop().result) - info = async_track_template_result( - hass, [TrackTemplate(template_complex, None)], specific_run_callback - ) - await hass.async_block_till_done() + info = async_track_template_result( + hass, [TrackTemplate(template_complex, None)], specific_run_callback + ) + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - hass.states.async_set("binary_sensor.washing_machine", "off") - await hass.async_block_till_done() + hass.states.async_set("binary_sensor.washing_machine", "off") + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": False, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": False, + } - hass.states.async_set("binary_sensor.washing_machine", "on") - await hass.async_block_till_done() + hass.states.async_set("binary_sensor.washing_machine", "on") + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - # Verify we do not update before the minute rolls over - callback_count_before_time_change = len(specific_runs) - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + # Verify we do not update before the minute rolls over + callback_count_before_time_change = len(specific_runs) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - async_fire_time_changed(hass, test_time + timedelta(seconds=58)) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + new_time = test_time + timedelta(seconds=58) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - # Verify we do update on the next change of minute - async_fire_time_changed(hass, test_time + timedelta(seconds=59)) - - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + 1 + # Verify we do update on the next change of minute + new_time = test_time + timedelta(seconds=59) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change + 1 info.async_remove() async def test_async_track_template_result_multiple_templates_mixing_listeners( - hass: HomeAssistant, + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test tracking multiple templates with mixing listener types.""" @@ -3410,18 +3415,16 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - info = async_track_template_result( - hass, - [ - TrackTemplate(template_1, None), - TrackTemplate(template_2, None), - ], - refresh_listener, - ) + info = async_track_template_result( + hass, + [ + TrackTemplate(template_1, None), + TrackTemplate(template_2, None), + ], + refresh_listener, + ) assert info.listeners == { "all": False, @@ -3450,9 +3453,9 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( refresh_runs = [] next_time = time_that_will_not_match_right_away + timedelta(hours=25) - with patch("homeassistant.util.dt.utcnow", return_value=next_time): - async_fire_time_changed(hass, next_time) - await hass.async_block_till_done() + freezer.move_to(next_time) + async_fire_time_changed(hass, next_time) + await hass.async_block_till_done() assert refresh_runs == [ [ @@ -3787,7 +3790,10 @@ async def test_track_sunset(hass: HomeAssistant) -> None: assert len(offset_runs) == 1 -async def test_async_track_time_change(hass: HomeAssistant) -> None: +async def test_async_track_time_change( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test tracking time change.""" none_runs = [] wildcard_runs = [] @@ -3798,21 +3804,19 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) - unsub_utc = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] - ) - unsub_wildcard = async_track_time_change( - hass, - callback(lambda x: wildcard_runs.append(x)), - second="*", - minute="*", - hour="*", - ) + unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) + unsub_utc = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] + ) + unsub_wildcard = async_track_time_change( + hass, + callback(lambda x: wildcard_runs.append(x)), + second="*", + minute="*", + hour="*", + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3851,7 +3855,10 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: assert len(none_runs) == 3 -async def test_periodic_task_minute(hass: HomeAssistant) -> None: +async def test_periodic_task_minute( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per minute.""" specific_runs = [] @@ -3860,13 +3867,11 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 - ) + unsub = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3895,7 +3900,10 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: assert len(specific_runs) == 2 -async def test_periodic_task_hour(hass: HomeAssistant) -> None: +async def test_periodic_task_hour( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per hour.""" specific_runs = [] @@ -3904,17 +3912,15 @@ async def test_periodic_task_hour(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3973,71 +3979,77 @@ async def test_periodic_task_wrong_input(hass: HomeAssistant) -> None: assert len(specific_runs) == 0 -async def test_periodic_task_clock_rollback(hass: HomeAssistant) -> None: +async def test_periodic_task_clock_rollback( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic tasks with the time rolling backwards.""" specific_runs = [] now = dt_util.utcnow() - time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) - - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, ) + + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 unsub() - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 -async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: +async def test_periodic_task_duplicate_time( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks not triggering on duplicate time.""" specific_runs = [] @@ -4046,17 +4058,15 @@ async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 7e655a69c0a..c2bad6287ab 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -9,6 +9,7 @@ from types import MappingProxyType from unittest import mock from unittest.mock import AsyncMock, MagicMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -1346,13 +1347,13 @@ async def test_wait_template_with_utcnow(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running match_time = start_time.replace(hour=12) - with patch("homeassistant.util.dt.utcnow", return_value=match_time): + with freeze_time(match_time): async_fire_time_changed(hass, match_time) except (AssertionError, asyncio.TimeoutError): await script_obj.async_stop() @@ -1378,15 +1379,13 @@ async def test_wait_template_with_utcnow_no_match(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running second_non_matching_time = start_time.replace(hour=4) - with patch( - "homeassistant.util.dt.utcnow", return_value=second_non_matching_time - ): + with freeze_time(second_non_matching_time): async_fire_time_changed(hass, second_non_matching_time) async with asyncio.timeout(0.1): diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index e030958ab82..b6dc1616a48 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -1,8 +1,8 @@ """The tests for the Sun helpers.""" from datetime import datetime, timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET @@ -77,7 +77,7 @@ def test_next_events(hass: HomeAssistant) -> None: break mod += 1 - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert next_dawn == sun.get_astral_event_next(hass, "dawn") assert next_dusk == sun.get_astral_event_next(hass, "dusk") assert next_midnight == sun.get_astral_event_next(hass, "midnight") @@ -132,7 +132,7 @@ def test_date_events_default_date(hass: HomeAssistant) -> None: sunrise = astral.sun.sunrise(location.observer, date=utc_today) sunset = astral.sun.sunset(location.observer, date=utc_today) - with patch("homeassistant.util.dt.now", return_value=utc_now): + with freeze_time(utc_now): assert dawn == sun.get_astral_event_date(hass, "dawn", utc_today) assert dusk == sun.get_astral_event_date(hass, "dusk", utc_today) assert midnight == sun.get_astral_event_date(hass, "midnight", utc_today) @@ -171,11 +171,11 @@ def test_date_events_accepts_datetime(hass: HomeAssistant) -> None: def test_is_up(hass: HomeAssistant) -> None: """Test retrieving next sun events.""" utc_now = datetime(2016, 11, 1, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert not sun.is_up(hass) utc_now = datetime(2016, 11, 1, 18, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert sun.is_up(hass) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 79358ec588d..d1294d02f05 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1853,7 +1853,7 @@ def test_has_value(hass: HomeAssistant) -> None: def test_now(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" now = dt_util.now() - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): info = template.Template("{{ now().isoformat() }}", hass).async_render_to_info() assert now.isoformat() == info.result() @@ -1867,7 +1867,7 @@ def test_now(mock_is_safe, hass: HomeAssistant) -> None: def test_utcnow(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + with freeze_time(utcnow): info = template.Template( "{{ utcnow().isoformat() }}", hass ).async_render_to_info() @@ -1954,7 +1954,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: relative_time_template = ( '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' ) - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( relative_time_template, hass, @@ -2026,7 +2026,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: def test_timedelta(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( "{{timedelta(seconds=120)}}", hass, diff --git a/tests/test_core.py b/tests/test_core.py index d5b3ba5f87d..ce1767f2755 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,6 +14,7 @@ import time from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch +from freezegun import freeze_time import pytest from pytest_unordered import unordered import voluptuous as vol @@ -1129,7 +1130,7 @@ async def test_statemachine_last_changed_not_updated_on_same_state( future = dt_util.utcnow() + timedelta(hours=10) - with patch("homeassistant.util.dt.utcnow", return_value=future): + with freeze_time(future): hass.states.async_set("light.Bowl", "on", {"attr": "triggers_change"}) await hass.async_block_till_done() From e9f8e7ab5093039c9cf2ab0e9a4f4d1f98418aa4 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 7 Dec 2023 16:02:55 -0600 Subject: [PATCH 579/643] Add Wyoming satellite audio settings (#105261) * Add noise suppression level * Add auto gain and volume multiplier * Always use mock TTS dir in Wyoming tests * More tests --- homeassistant/components/wyoming/__init__.py | 7 +- homeassistant/components/wyoming/devices.py | 56 ++++++++++ homeassistant/components/wyoming/number.py | 102 ++++++++++++++++++ homeassistant/components/wyoming/satellite.py | 12 +++ homeassistant/components/wyoming/select.py | 55 +++++++++- homeassistant/components/wyoming/strings.json | 19 +++- tests/components/wyoming/__init__.py | 21 ++++ tests/components/wyoming/conftest.py | 6 ++ .../components/wyoming/test_binary_sensor.py | 5 +- tests/components/wyoming/test_number.py | 102 ++++++++++++++++++ tests/components/wyoming/test_select.py | 60 ++++++++++- tests/components/wyoming/test_switch.py | 9 ++ tests/components/wyoming/test_tts.py | 6 -- 13 files changed, 445 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/wyoming/number.py create mode 100644 tests/components/wyoming/test_number.py diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 2cc9b7050a0..88e490d6dc9 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -17,7 +17,12 @@ from .satellite import WyomingSatellite _LOGGER = logging.getLogger(__name__) -SATELLITE_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SWITCH] +SATELLITE_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SWITCH, + Platform.NUMBER, +] __all__ = [ "ATTR_SPEAKER", diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py index 90dad889707..bd7252bcf6b 100644 --- a/homeassistant/components/wyoming/devices.py +++ b/homeassistant/components/wyoming/devices.py @@ -19,10 +19,14 @@ class SatelliteDevice: is_active: bool = False is_enabled: bool = True pipeline_name: str | None = None + noise_suppression_level: int = 0 + auto_gain: int = 0 + volume_multiplier: float = 1.0 _is_active_listener: Callable[[], None] | None = None _is_enabled_listener: Callable[[], None] | None = None _pipeline_listener: Callable[[], None] | None = None + _audio_settings_listener: Callable[[], None] | None = None @callback def set_is_active(self, active: bool) -> None: @@ -48,6 +52,30 @@ class SatelliteDevice: if self._pipeline_listener is not None: self._pipeline_listener() + @callback + def set_noise_suppression_level(self, noise_suppression_level: int) -> None: + """Set noise suppression level.""" + if noise_suppression_level != self.noise_suppression_level: + self.noise_suppression_level = noise_suppression_level + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_auto_gain(self, auto_gain: int) -> None: + """Set auto gain amount.""" + if auto_gain != self.auto_gain: + self.auto_gain = auto_gain + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_volume_multiplier(self, volume_multiplier: float) -> None: + """Set auto gain amount.""" + if volume_multiplier != self.volume_multiplier: + self.volume_multiplier = volume_multiplier + if self._audio_settings_listener is not None: + self._audio_settings_listener() + @callback def set_is_active_listener(self, is_active_listener: Callable[[], None]) -> None: """Listen for updates to is_active.""" @@ -63,6 +91,13 @@ class SatelliteDevice: """Listen for updates to pipeline.""" self._pipeline_listener = pipeline_listener + @callback + def set_audio_settings_listener( + self, audio_settings_listener: Callable[[], None] + ) -> None: + """Listen for updates to audio settings.""" + self._audio_settings_listener = audio_settings_listener + def get_assist_in_progress_entity_id(self, hass: HomeAssistant) -> str | None: """Return entity id for assist in progress binary sensor.""" ent_reg = er.async_get(hass) @@ -83,3 +118,24 @@ class SatelliteDevice: return ent_reg.async_get_entity_id( "select", DOMAIN, f"{self.satellite_id}-pipeline" ) + + def get_noise_suppression_level_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for noise suppression select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-noise_suppression_level" + ) + + def get_auto_gain_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for auto gain amount.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-auto_gain" + ) + + def get_volume_multiplier_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for microphone volume multiplier.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-volume_multiplier" + ) diff --git a/homeassistant/components/wyoming/number.py b/homeassistant/components/wyoming/number.py new file mode 100644 index 00000000000..5e769eeb06d --- /dev/null +++ b/homeassistant/components/wyoming/number.py @@ -0,0 +1,102 @@ +"""Number entities for Wyoming integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from homeassistant.components.number import NumberEntityDescription, RestoreNumber +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + +_MAX_AUTO_GAIN: Final = 31 +_MIN_VOLUME_MULTIPLIER: Final = 0.1 +_MAX_VOLUME_MULTIPLIER: Final = 10.0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming number entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + device = item.satellite.device + async_add_entities( + [ + WyomingSatelliteAutoGainNumber(device), + WyomingSatelliteVolumeMultiplierNumber(device), + ] + ) + + +class WyomingSatelliteAutoGainNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent auto gain amount.""" + + entity_description = NumberEntityDescription( + key="auto_gain", + translation_key="auto_gain", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = 0 + _attr_native_max_value = _MAX_AUTO_GAIN + _attr_native_value = 0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None: + await self.async_set_native_value(float(state.state)) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + auto_gain = int(max(0, min(_MAX_AUTO_GAIN, value))) + self._attr_native_value = auto_gain + self.async_write_ha_state() + self._device.set_auto_gain(auto_gain) + + +class WyomingSatelliteVolumeMultiplierNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent microphone volume multiplier.""" + + entity_description = NumberEntityDescription( + key="volume_multiplier", + translation_key="volume_multiplier", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = _MIN_VOLUME_MULTIPLIER + _attr_native_max_value = _MAX_VOLUME_MULTIPLIER + _attr_native_step = 0.1 + _attr_native_value = 1.0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + last_number_data = await self.async_get_last_number_data() + if (last_number_data is not None) and ( + last_number_data.native_value is not None + ): + await self.async_set_native_value(last_number_data.native_value) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + self._attr_native_value = float( + max(_MIN_VOLUME_MULTIPLIER, min(_MAX_VOLUME_MULTIPLIER, value)) + ) + self.async_write_ha_state() + self._device.set_volume_multiplier(self._attr_native_value) diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index caf65db115e..1cc3fde2a9c 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -60,6 +60,7 @@ class WyomingSatellite: self.device.set_is_enabled_listener(self._enabled_changed) self.device.set_pipeline_listener(self._pipeline_changed) + self.device.set_audio_settings_listener(self._audio_settings_changed) async def run(self) -> None: """Run and maintain a connection to satellite.""" @@ -135,6 +136,12 @@ class WyomingSatellite: # Cancel any running pipeline self._audio_queue.put_nowait(None) + def _audio_settings_changed(self) -> None: + """Run when device audio settings.""" + + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + async def _run_once(self) -> None: """Run pipelines until an error occurs.""" self.device.set_is_active(False) @@ -227,6 +234,11 @@ class WyomingSatellite: end_stage=end_stage, tts_audio_output="wav", pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + noise_suppression_level=self.device.noise_suppression_level, + auto_gain_dbfs=self.device.auto_gain, + volume_multiplier=self.device.volume_multiplier, + ), ) ) diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py index 2929ae79fa0..c04bad4bef8 100644 --- a/homeassistant/components/wyoming/select.py +++ b/homeassistant/components/wyoming/select.py @@ -1,12 +1,15 @@ -"""Select entities for VoIP integration.""" +"""Select entities for Wyoming integration.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -16,19 +19,34 @@ from .entity import WyomingSatelliteEntity if TYPE_CHECKING: from .models import DomainDataItem +_NOISE_SUPPRESSION_LEVEL: Final = { + "off": 0, + "low": 1, + "medium": 2, + "high": 3, + "max": 4, +} +_DEFAULT_NOISE_SUPPRESSION_LEVEL: Final = "off" + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up VoIP switch entities.""" + """Set up Wyoming select entities.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] # Setup is only forwarded for satellites assert item.satellite is not None - async_add_entities([WyomingSatellitePipelineSelect(hass, item.satellite.device)]) + device = item.satellite.device + async_add_entities( + [ + WyomingSatellitePipelineSelect(hass, device), + WyomingSatelliteNoiseSuppressionLevelSelect(device), + ] + ) class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelect): @@ -45,3 +63,32 @@ class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelec """Select an option.""" await super().async_select_option(option) self.device.set_pipeline_name(option) + + +class WyomingSatelliteNoiseSuppressionLevelSelect( + WyomingSatelliteEntity, SelectEntity, restore_state.RestoreEntity +): + """Entity to represent noise suppression level setting.""" + + entity_description = SelectEntityDescription( + key="noise_suppression_level", + translation_key="noise_suppression_level", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_current_option = _DEFAULT_NOISE_SUPPRESSION_LEVEL + _attr_options = list(_NOISE_SUPPRESSION_LEVEL.keys()) + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None and state.state in self.options: + self._attr_current_option = state.state + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + self._attr_current_option = option + self.async_write_ha_state() + self._device.set_noise_suppression_level(_NOISE_SUPPRESSION_LEVEL[option]) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 19b6a513d4b..7b6be68aeb2 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -37,14 +37,29 @@ "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" } }, - "noise_suppression": { - "name": "Noise suppression" + "noise_suppression_level": { + "name": "Noise suppression level", + "state": { + "off": "Off", + "low": "Low", + "medium": "Medium", + "high": "High", + "max": "Max" + } } }, "switch": { "satellite_enabled": { "name": "Satellite enabled" } + }, + "number": { + "auto_gain": { + "name": "Auto gain" + }, + "volume_multiplier": { + "name": "Mic volume" + } } } } diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index 899eda7ec1a..268ebef1d06 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,5 +1,6 @@ """Tests for the Wyoming integration.""" import asyncio +from unittest.mock import patch from wyoming.event import Event from wyoming.info import ( @@ -15,6 +16,10 @@ from wyoming.info import ( WakeProgram, ) +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.core import HomeAssistant + TEST_ATTR = Attribution(name="Test", url="http://www.test.com") STT_INFO = Info( asr=[ @@ -124,3 +129,19 @@ class MockAsyncTcpClient: self.host = host self.port = port return self + + +async def reload_satellite( + hass: HomeAssistant, config_entry_id: str +) -> SatelliteDevice: + """Reload config entry with satellite info and returns new device.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.run" + ) as _run_mock: + # _run_mock: satellite task does not actually run + await hass.config_entries.async_reload(config_entry_id) + + return hass.data[DOMAIN][config_entry_id].satellite.device diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index a30c1048eb6..f22ec7e9e16 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -16,6 +16,12 @@ from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + @pytest.fixture(autouse=True) async def init_components(hass: HomeAssistant): """Set up required components.""" diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py index 27294186a90..fba181a63ca 100644 --- a/tests/components/wyoming/test_binary_sensor.py +++ b/tests/components/wyoming/test_binary_sensor.py @@ -4,6 +4,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from . import reload_satellite + async def test_assist_in_progress( hass: HomeAssistant, @@ -26,7 +28,8 @@ async def test_assist_in_progress( assert state.state == STATE_ON assert satellite_device.is_active - satellite_device.set_is_active(False) + # test restore does *not* happen + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) state = hass.states.get(assist_in_progress_id) assert state is not None diff --git a/tests/components/wyoming/test_number.py b/tests/components/wyoming/test_number.py new file mode 100644 index 00000000000..084021d61a7 --- /dev/null +++ b/tests/components/wyoming/test_number.py @@ -0,0 +1,102 @@ +"""Test Wyoming number.""" +from unittest.mock import patch + +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import reload_satellite + + +async def test_auto_gain_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test automatic gain control number.""" + agc_entity_id = satellite_device.get_auto_gain_entity_id(hass) + assert agc_entity_id + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 0 + assert satellite_device.auto_gain == 0 + + # Change setting + with patch.object(satellite_device, "set_auto_gain") as mock_agc_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 31}, + blocking=True, + ) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + # set function should have been called + mock_agc_changed.assert_called_once_with(31) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 15}, + blocking=True, + ) + + assert satellite_device.auto_gain == 15 + + +async def test_volume_multiplier_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test volume multiplier number.""" + vm_entity_id = satellite_device.get_volume_multiplier_entity_id(hass) + assert vm_entity_id + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 1.0 + assert satellite_device.volume_multiplier == 1.0 + + # Change setting + with patch.object(satellite_device, "set_volume_multiplier") as mock_vm_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 2.0}, + blocking=True, + ) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + # set function should have been called + mock_vm_changed.assert_called_once_with(2.0) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 0.5}, + blocking=True, + ) + + assert float(satellite_device.volume_multiplier) == 0.5 diff --git a/tests/components/wyoming/test_select.py b/tests/components/wyoming/test_select.py index cab699336fb..128aab57a1a 100644 --- a/tests/components/wyoming/test_select.py +++ b/tests/components/wyoming/test_select.py @@ -9,6 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import reload_satellite + async def test_pipeline_select( hass: HomeAssistant, @@ -61,9 +63,16 @@ async def test_pipeline_select( assert state is not None assert state.state == "Test 1" - # async_pipeline_changed should have been called + # set function should have been called mock_pipeline_changed.assert_called_once_with("Test 1") + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "Test 1" + # Change back and check update listener pipeline_listener = Mock() satellite_device.set_pipeline_listener(pipeline_listener) @@ -81,3 +90,52 @@ async def test_pipeline_select( # listener should have been called pipeline_listener.assert_called_once() + + +async def test_noise_suppression_level_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test noise suppression level select.""" + nsl_entity_id = satellite_device.get_noise_suppression_level_entity_id(hass) + assert nsl_entity_id + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "off" + assert satellite_device.noise_suppression_level == 0 + + # Change setting + with patch.object( + satellite_device, "set_noise_suppression_level" + ) as mock_nsl_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "max"}, + blocking=True, + ) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + # set function should have been called + mock_nsl_changed.assert_called_once_with(4) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "medium"}, + blocking=True, + ) + + assert satellite_device.noise_suppression_level == 2 diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py index 0b05724d761..a39b7087f6d 100644 --- a/tests/components/wyoming/test_switch.py +++ b/tests/components/wyoming/test_switch.py @@ -4,6 +4,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from . import reload_satellite + async def test_satellite_enabled( hass: HomeAssistant, @@ -30,3 +32,10 @@ async def test_satellite_enabled( assert state is not None assert state.state == STATE_OFF assert not satellite_device.is_enabled + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_OFF diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 2f2a25558e4..301074e8ffb 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -16,12 +16,6 @@ from homeassistant.helpers.entity_component import DATA_INSTANCES from . import MockAsyncTcpClient -@pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): - """Mock the TTS cache dir with empty dir.""" - return mock_tts_cache_dir - - async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None: """Test supported properties.""" state = hass.states.get("tts.test_tts") From 43daeb26303208c0cf2183d548a705f840fa1fb5 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 7 Dec 2023 19:44:43 -0600 Subject: [PATCH 580/643] Set device id and forward errors to Wyoming satellites (#105266) * Set device id and forward errors * Fix tests --- .../components/wyoming/manifest.json | 2 +- homeassistant/components/wyoming/satellite.py | 12 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../wyoming/snapshots/test_stt.ambr | 2 +- tests/components/wyoming/test_satellite.py | 50 ++++++++++++++++++- 6 files changed, 65 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 540aaa9aeac..7174683fd18 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.3.0"], + "requirements": ["wyoming==1.4.0"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 1cc3fde2a9c..94f61c17047 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -9,6 +9,7 @@ import wave from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop from wyoming.client import AsyncTcpClient +from wyoming.error import Error from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite from wyoming.tts import Synthesize, SynthesizeVoice @@ -239,6 +240,7 @@ class WyomingSatellite: auto_gain_dbfs=self.device.auto_gain, volume_multiplier=self.device.volume_multiplier, ), + device_id=self.device.device_id, ) ) @@ -333,6 +335,16 @@ class WyomingSatellite: if event.data and (tts_output := event.data["tts_output"]): media_id = tts_output["media_id"] self.hass.add_job(self._stream_tts(media_id)) + elif event.type == assist_pipeline.PipelineEventType.ERROR: + # Pipeline error + if event.data: + self.hass.add_job( + self._client.write_event( + Error( + text=event.data["message"], code=event.data["code"] + ).event() + ) + ) async def _connect(self) -> None: """Connect to satellite over TCP.""" diff --git a/requirements_all.txt b/requirements_all.txt index e4fcb0cb396..935f5f78075 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2760,7 +2760,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.3.0 +wyoming==1.4.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e46bcbbe862..741b40b5ee4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2064,7 +2064,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.3.0 +wyoming==1.4.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/tests/components/wyoming/snapshots/test_stt.ambr b/tests/components/wyoming/snapshots/test_stt.ambr index 784f89b2ab8..b45b7508b28 100644 --- a/tests/components/wyoming/snapshots/test_stt.ambr +++ b/tests/components/wyoming/snapshots/test_stt.ambr @@ -6,7 +6,7 @@ 'language': 'en', }), 'payload': None, - 'type': 'transcibe', + 'type': 'transcribe', }), dict({ 'data': dict({ diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 06ae337a19c..50252007aa5 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -8,6 +8,7 @@ import wave from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.error import Error from wyoming.event import Event from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite @@ -96,6 +97,9 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): self.tts_audio_stop_event = asyncio.Event() self.tts_audio_chunk: AudioChunk | None = None + self.error_event = asyncio.Event() + self.error: Error | None = None + self._mic_audio_chunk = AudioChunk( rate=16000, width=2, channels=1, audio=b"chunk" ).event() @@ -135,6 +139,9 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): self.tts_audio_chunk_event.set() elif AudioStop.is_type(event.type): self.tts_audio_stop_event.set() + elif Error.is_type(event.type): + self.error = Error.from_event(event) + self.error_event.set() async def read_event(self) -> Event | None: """Receive.""" @@ -175,8 +182,9 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: await mock_client.connect_event.wait() await mock_client.run_satellite_event.wait() - mock_run_pipeline.assert_called() + mock_run_pipeline.assert_called_once() event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + assert mock_run_pipeline.call_args.kwargs.get("device_id") == device.device_id # Start detecting wake word event_callback( @@ -458,3 +466,43 @@ async def test_satellite_disconnect_during_pipeline(hass: HomeAssistant) -> None # Sensor should have been turned off assert not device.is_active + + +async def test_satellite_error_during_pipeline(hass: HomeAssistant) -> None: + """Test satellite error occurring during pipeline run.""" + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] # no audio chunks after RunPipeline + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline: + await setup_config_entry(hass) + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called_once() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.ERROR, + {"code": "test code", "message": "test message"}, + ) + ) + + async with asyncio.timeout(1): + await mock_client.error_event.wait() + + assert mock_client.error is not None + assert mock_client.error.text == "test message" + assert mock_client.error.code == "test code" From 664d2410d546c729581a0ec138171b960196c741 Mon Sep 17 00:00:00 2001 From: osohotwateriot <102795312+osohotwateriot@users.noreply.github.com> Date: Fri, 8 Dec 2023 09:51:59 +0200 Subject: [PATCH 581/643] Add OSO Energy integration (#70365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add OSO Energy integration * Add min/max for v40 level and bump pyosoenergyapi to 1.0.2 * OSO Energy address review comments * Bump pyosoenergyapi to 1.0.3 and remove scan interval * Remove unnecessary code * Update homeassistant/components/osoenergy/__init__.py Co-authored-by: Daniel Hjelseth Høyer * Fixes to latest version * Add support to set temperature * Update homeassistant/components/osoenergy/config_flow.py Co-authored-by: Daniel Hjelseth Høyer * Fixes after review * Remove unused code * Add support for translations and modify services * Apply suggestions from code review Co-authored-by: Robert Resch * Refactor services and constants according to the PR suggestions * Remove unnecessary code * Remove unused import in constants * Refactoring and support for multiple instances * Apply suggestions from code review Co-authored-by: Robert Resch * Refactor code and apply review suggestions * Bump pyosoenergyapi to 1.0.5 * Remove services to reduce initial PR * Remove extra state attributes and make OSO Entity generic --------- Co-authored-by: Daniel Hjelseth Høyer Co-authored-by: Daniel Hjelseth Høyer Co-authored-by: Robert Resch --- .coveragerc | 3 + CODEOWNERS | 2 + .../components/osoenergy/__init__.py | 81 +++++++++ .../components/osoenergy/config_flow.py | 75 ++++++++ homeassistant/components/osoenergy/const.py | 3 + .../components/osoenergy/manifest.json | 9 + .../components/osoenergy/strings.json | 29 ++++ .../components/osoenergy/water_heater.py | 142 +++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/osoenergy/__init__.py | 1 + .../components/osoenergy/test_config_flow.py | 164 ++++++++++++++++++ 14 files changed, 522 insertions(+) create mode 100644 homeassistant/components/osoenergy/__init__.py create mode 100644 homeassistant/components/osoenergy/config_flow.py create mode 100644 homeassistant/components/osoenergy/const.py create mode 100644 homeassistant/components/osoenergy/manifest.json create mode 100644 homeassistant/components/osoenergy/strings.json create mode 100644 homeassistant/components/osoenergy/water_heater.py create mode 100644 tests/components/osoenergy/__init__.py create mode 100644 tests/components/osoenergy/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 9e5044dff64..c012c8e686e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -903,6 +903,9 @@ omit = homeassistant/components/opple/light.py homeassistant/components/oru/* homeassistant/components/orvibo/switch.py + homeassistant/components/osoenergy/__init__.py + homeassistant/components/osoenergy/const.py + homeassistant/components/osoenergy/water_heater.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py homeassistant/components/overkiz/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index e618db415c6..9bcc3daac17 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -930,6 +930,8 @@ build.json @home-assistant/supervisor /homeassistant/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L /homeassistant/components/oru/ @bvlaicu +/homeassistant/components/osoenergy/ @osohotwateriot +/tests/components/osoenergy/ @osohotwateriot /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core /homeassistant/components/ourgroceries/ @OnFreund diff --git a/homeassistant/components/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py new file mode 100644 index 00000000000..f0b89eaea90 --- /dev/null +++ b/homeassistant/components/osoenergy/__init__.py @@ -0,0 +1,81 @@ +"""Support for the OSO Energy devices and services.""" +from typing import Any, Generic, TypeVar + +from aiohttp.web_exceptions import HTTPException +from apyosoenergyapi import OSOEnergy +from apyosoenergyapi.helper.const import ( + OSOEnergyBinarySensorData, + OSOEnergySensorData, + OSOEnergyWaterHeaterData, +) +from apyosoenergyapi.helper.osoenergy_exceptions import OSOEnergyReauthRequired + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +_T = TypeVar( + "_T", OSOEnergyBinarySensorData, OSOEnergySensorData, OSOEnergyWaterHeaterData +) + +PLATFORMS = [ + Platform.WATER_HEATER, +] +PLATFORM_LOOKUP = { + Platform.WATER_HEATER: "water_heater", +} + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OSO Energy from a config entry.""" + subscription_key = entry.data[CONF_API_KEY] + websession = aiohttp_client.async_get_clientsession(hass) + osoenergy = OSOEnergy(subscription_key, websession) + + osoenergy_config = dict(entry.data) + + hass.data.setdefault(DOMAIN, {}) + + try: + devices: Any = await osoenergy.session.start_session(osoenergy_config) + except HTTPException as error: + raise ConfigEntryNotReady() from error + except OSOEnergyReauthRequired as err: + raise ConfigEntryAuthFailed from err + + hass.data[DOMAIN][entry.entry_id] = osoenergy + + platforms = set() + for ha_type, oso_type in PLATFORM_LOOKUP.items(): + device_list = devices.get(oso_type, []) + if device_list: + platforms.add(ha_type) + if platforms: + await hass.config_entries.async_forward_entry_setups(entry, platforms) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class OSOEnergyEntity(Entity, Generic[_T]): + """Initiate OSO Energy Base Class.""" + + _attr_has_entity_name = True + + def __init__(self, osoenergy: OSOEnergy, osoenergy_device: _T) -> None: + """Initialize the instance.""" + self.osoenergy = osoenergy + self.device = osoenergy_device + self._attr_unique_id = osoenergy_device.device_id diff --git a/homeassistant/components/osoenergy/config_flow.py b/homeassistant/components/osoenergy/config_flow.py new file mode 100644 index 00000000000..a7632b19bcb --- /dev/null +++ b/homeassistant/components/osoenergy/config_flow.py @@ -0,0 +1,75 @@ +"""Config Flow for OSO Energy.""" +from collections.abc import Mapping +import logging +from typing import Any + +from apyosoenergyapi import OSOEnergy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +_SCHEMA_STEP_USER = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +class OSOEnergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a OSO Energy config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self) -> None: + """Initialize.""" + self.entry: ConfigEntry | None = None + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + # Verify Subscription key + if user_email := await self.get_user_email(user_input[CONF_API_KEY]): + await self.async_set_unique_id(user_email) + + if ( + self.context["source"] == config_entries.SOURCE_REAUTH + and self.entry + ): + self.hass.config_entries.async_update_entry( + self.entry, title=user_email, data=user_input + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_email, data=user_input) + + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", + data_schema=_SCHEMA_STEP_USER, + errors=errors, + ) + + async def get_user_email(self, subscription_key: str) -> str | None: + """Return the user email for the provided subscription key.""" + try: + websession = aiohttp_client.async_get_clientsession(self.hass) + client = OSOEnergy(subscription_key, websession) + return await client.get_user_email() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error occurred") + return None + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Re Authenticate a user.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + data = {CONF_API_KEY: user_input[CONF_API_KEY]} + return await self.async_step_user(data) diff --git a/homeassistant/components/osoenergy/const.py b/homeassistant/components/osoenergy/const.py new file mode 100644 index 00000000000..c3925f5259b --- /dev/null +++ b/homeassistant/components/osoenergy/const.py @@ -0,0 +1,3 @@ +"""Constants for OSO Energy.""" + +DOMAIN = "osoenergy" diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json new file mode 100644 index 00000000000..d6813108242 --- /dev/null +++ b/homeassistant/components/osoenergy/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "osoenergy", + "name": "OSO Energy", + "codeowners": ["@osohotwateriot"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/osoenergy", + "iot_class": "cloud_polling", + "requirements": ["pyosoenergyapi==1.1.3"] +} diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json new file mode 100644 index 00000000000..a45482bf030 --- /dev/null +++ b/homeassistant/components/osoenergy/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "OSO Energy Auth", + "description": "Enter the generated 'Subscription Key' for your account at 'https://portal.osoenergy.no/'", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth": { + "title": "OSO Energy Auth", + "description": "Generate and enter a new 'Subscription Key' for your account at 'https://portal.osoenergy.no/'.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py new file mode 100644 index 00000000000..4b2ad7c48d6 --- /dev/null +++ b/homeassistant/components/osoenergy/water_heater.py @@ -0,0 +1,142 @@ +"""Support for OSO Energy water heaters.""" +from typing import Any + +from apyosoenergyapi.helper.const import OSOEnergyWaterHeaterData + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HIGH_DEMAND, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OSOEnergyEntity +from .const import DOMAIN + +CURRENT_OPERATION_MAP: dict[str, Any] = { + "default": { + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, + "oso": { + "auto": STATE_ECO, + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, +} +HEATER_MIN_TEMP = 10 +HEATER_MAX_TEMP = 80 +MANUFACTURER = "OSO Energy" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up OSO Energy heater based on a config entry.""" + osoenergy = hass.data[DOMAIN][entry.entry_id] + devices = osoenergy.session.device_list.get("water_heater") + entities = [] + if devices: + for dev in devices: + entities.append(OSOEnergyWaterHeater(osoenergy, dev)) + async_add_entities(entities, True) + + +class OSOEnergyWaterHeater( + OSOEnergyEntity[OSOEnergyWaterHeaterData], WaterHeaterEntity +): + """OSO Energy Water Heater Device.""" + + _attr_name = None + _attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.device_id)}, + manufacturer=MANUFACTURER, + model=self.device.device_type, + name=self.device.device_name, + ) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self.device.available + + @property + def current_operation(self) -> str: + """Return current operation.""" + status = self.device.current_operation + if status == "off": + return STATE_OFF + + optimization_mode = self.device.optimization_mode.lower() + heater_mode = self.device.heater_mode.lower() + if optimization_mode in CURRENT_OPERATION_MAP: + return CURRENT_OPERATION_MAP[optimization_mode].get( + heater_mode, STATE_ELECTRIC + ) + + return CURRENT_OPERATION_MAP["default"].get(heater_mode, STATE_ELECTRIC) + + @property + def current_temperature(self) -> float: + """Return the current temperature of the heater.""" + return self.device.current_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature + + @property + def target_temperature_high(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_high + + @property + def target_temperature_low(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_low + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.device.min_temperature + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device.max_temperature + + async def async_turn_on(self, **kwargs) -> None: + """Turn on hotwater.""" + await self.osoenergy.hotwater.turn_on(self.device, True) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off hotwater.""" + await self.osoenergy.hotwater.turn_off(self.device, True) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + target_temperature = int(kwargs.get("temperature", self.target_temperature)) + profile = [target_temperature] * 24 + + await self.osoenergy.hotwater.set_profile(self.device, profile) + + async def async_update(self) -> None: + """Update all Node data from Hive.""" + await self.osoenergy.session.update_data() + self.device = await self.osoenergy.hotwater.get_water_heater(self.device) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index aa13faaf501..6315d2db46e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -348,6 +348,7 @@ FLOWS = { "openweathermap", "opower", "oralb", + "osoenergy", "otbr", "ourgroceries", "overkiz", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 36aae4f799b..652fc11411e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4150,6 +4150,12 @@ "config_flow": false, "iot_class": "local_push" }, + "osoenergy": { + "name": "OSO Energy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "osramlightify": { "name": "Osramlightify", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 935f5f78075..77fe9c3801a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1955,6 +1955,9 @@ pyopnsense==0.4.0 # homeassistant.components.opple pyoppleio-legacy==1.0.8 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 741b40b5ee4..cdc63c097f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1478,6 +1478,9 @@ pyopenuv==2023.02.0 # homeassistant.components.opnsense pyopnsense==0.4.0 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 diff --git a/tests/components/osoenergy/__init__.py b/tests/components/osoenergy/__init__.py new file mode 100644 index 00000000000..76d134ef0f5 --- /dev/null +++ b/tests/components/osoenergy/__init__.py @@ -0,0 +1 @@ +"""Tests for the OSO Hotwater integration.""" diff --git a/tests/components/osoenergy/test_config_flow.py b/tests/components/osoenergy/test_config_flow.py new file mode 100644 index 00000000000..d7250356ebe --- /dev/null +++ b/tests/components/osoenergy/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the OSO Energy config flow.""" +from unittest.mock import patch + +from apyosoenergyapi.helper import osoenergy_exceptions + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.osoenergy.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +SUBSCRIPTION_KEY = "valid subscription key" +SCAN_INTERVAL = 120 +TEST_USER_EMAIL = "test_user_email@domain.com" +UPDATED_SCAN_INTERVAL = 60 + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ), patch( + "homeassistant.components.osoenergy.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == TEST_USER_EMAIL + assert result2["data"] == { + CONF_API_KEY: SUBSCRIPTION_KEY, + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test the reauth flow.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config.unique_id, + "entry_id": mock_config.entry_id, + }, + data=mock_config.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get(CONF_API_KEY) == SUBSCRIPTION_KEY + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: + """Check flow abort when an entry already exist.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_invalid_subscription_key(hass: HomeAssistant) -> None: + """Test user flow with invalid username.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_user_flow_exception_on_subscription_key_check( + hass: HomeAssistant, +) -> None: + """Test user flow with invalid username.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + side_effect=osoenergy_exceptions.OSOEnergyReauthRequired(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} From a7104919900ebd3f5b4d63bb31a820bf1964496e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Dec 2023 08:54:27 +0100 Subject: [PATCH 582/643] Bump actions/stale from 8.0.0 to 9.0.0 (#105275) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c91117cb02d..61dfef074a7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: # - No PRs marked as no-stale # - No issues (-1) - name: 90 days stale PRs policy - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 @@ -53,7 +53,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -83,7 +83,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" From 8879f7350a20df440e9db3ef7483d241dfab8e48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Dec 2023 08:55:25 +0100 Subject: [PATCH 583/643] Bump github/codeql-action from 2.22.8 to 2.22.9 (#105274) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e7d9d4cd901..c9e6bb8fcc8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.8 + uses: github/codeql-action/init@v2.22.9 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.8 + uses: github/codeql-action/analyze@v2.22.9 with: category: "/language:python" From 8aacd3ea1bad9cb1e036c0e6f4f4671b0448ab15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Dec 2023 21:56:32 -1000 Subject: [PATCH 584/643] Bump habluetooth to 0.10.0 (#105118) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 055eff43a91..5d54ae6ea82 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.17.0", "dbus-fast==2.20.0", - "habluetooth==0.8.0" + "habluetooth==0.10.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1673877b029..bb997222625 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ dbus-fast==2.20.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==0.8.0 +habluetooth==0.10.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 diff --git a/requirements_all.txt b/requirements_all.txt index 77fe9c3801a..0049dd4dd40 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.8.0 +habluetooth==0.10.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cdc63c097f7..02153886bfd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==0.8.0 +habluetooth==0.10.0 # homeassistant.components.cloud hass-nabucasa==0.74.0 From 97991cfa46a99c53c003d325c076a43572ec7ea2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Dec 2023 22:00:23 -1000 Subject: [PATCH 585/643] Bump pyunifiprotect to 4.22.0 (#105265) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index ee6f6d05548..045538aa2d1 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.21.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.22.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 0049dd4dd40..6d1c95e88b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2258,7 +2258,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.21.0 +pyunifiprotect==4.22.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02153886bfd..857581cdf2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1694,7 +1694,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.21.0 +pyunifiprotect==4.22.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From b29e9e2df0a9a154109df89f2d7a3321c9da65ee Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Fri, 8 Dec 2023 16:01:22 +0800 Subject: [PATCH 586/643] Fix AsusWrt invalid data type with tuple type (#105247) --- homeassistant/components/asuswrt/bridge.py | 6 ++++-- tests/components/asuswrt/conftest.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 83f99ecc76a..228da7f1a36 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -55,7 +55,9 @@ _LOGGER = logging.getLogger(__name__) _AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge") -_FuncType = Callable[[_AsusWrtBridgeT], Awaitable[list[Any] | dict[str, Any]]] +_FuncType = Callable[ + [_AsusWrtBridgeT], Awaitable[list[Any] | tuple[Any] | dict[str, Any]] +] _ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]] @@ -81,7 +83,7 @@ def handle_errors_and_zip( if isinstance(data, dict): return dict(zip(keys, list(data.values()))) - if not isinstance(data, list): + if not isinstance(data, (list, tuple)): raise UpdateFailed("Received invalid data type") return dict(zip(keys, data)) diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index 0f29c84c820..72cbc37d571 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -14,9 +14,9 @@ from .common import ASUSWRT_BASE, MOCK_MACS, ROUTER_MAC_ADDR, new_device ASUSWRT_HTTP_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtHttp" ASUSWRT_LEGACY_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtLegacy" -MOCK_BYTES_TOTAL = [60000000000, 50000000000] +MOCK_BYTES_TOTAL = 60000000000, 50000000000 MOCK_BYTES_TOTAL_HTTP = dict(enumerate(MOCK_BYTES_TOTAL)) -MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] +MOCK_CURRENT_TRANSFER_RATES = 20000000, 10000000 MOCK_CURRENT_TRANSFER_RATES_HTTP = dict(enumerate(MOCK_CURRENT_TRANSFER_RATES)) MOCK_LOAD_AVG_HTTP = {"load_avg_1": 1.1, "load_avg_5": 1.2, "load_avg_15": 1.3} MOCK_LOAD_AVG = list(MOCK_LOAD_AVG_HTTP.values()) From a09ccddaa3b6bc4b115b0861b02b1f3c6dcc2a29 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 8 Dec 2023 09:33:24 +0100 Subject: [PATCH 587/643] Fix Fritzbox light setup (#105232) --- homeassistant/components/fritzbox/light.py | 46 ++++++++++++---------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index d31ccd180c4..8dc51e59738 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -38,11 +38,9 @@ async def async_setup_entry( FritzboxLight( coordinator, ain, - device.get_colors(), - device.get_color_temps(), ) for ain in coordinator.new_devices - if (device := coordinator.data.devices[ain]).has_lightbulb + if (coordinator.data.devices[ain]).has_lightbulb ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) @@ -57,27 +55,10 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): self, coordinator: FritzboxDataUpdateCoordinator, ain: str, - supported_colors: dict, - supported_color_temps: list[int], ) -> None: """Initialize the FritzboxLight entity.""" super().__init__(coordinator, ain, None) - - if supported_color_temps: - # only available for color bulbs - self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) - self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) - - # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. - # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup self._supported_hs: dict[int, list[int]] = {} - for values in supported_colors.values(): - hue = int(values[0][0]) - self._supported_hs[hue] = [ - int(values[0][1]), - int(values[1][1]), - int(values[2][1]), - ] @property def is_on(self) -> bool: @@ -173,3 +154,28 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Turn the light off.""" await self.hass.async_add_executor_job(self.data.set_state_off) await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """Get light attributes from device after entity is added to hass.""" + await super().async_added_to_hass() + supported_colors = await self.hass.async_add_executor_job( + self.coordinator.data.devices[self.ain].get_colors + ) + supported_color_temps = await self.hass.async_add_executor_job( + self.coordinator.data.devices[self.ain].get_color_temps + ) + + if supported_color_temps: + # only available for color bulbs + self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) + self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) + + # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. + # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup + for values in supported_colors.values(): + hue = int(values[0][0]) + self._supported_hs[hue] = [ + int(values[0][1]), + int(values[1][1]), + int(values[2][1]), + ] From 156dac394a3d1ea72dd81c4ff4361107d1446fc0 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 8 Dec 2023 09:39:39 +0100 Subject: [PATCH 588/643] Add migration for old HomeWizard sensors (#105251) Co-authored-by: Franck Nijhof --- .../components/homewizard/__init__.py | 55 +++++++++- homeassistant/components/homewizard/const.py | 3 + homeassistant/components/homewizard/sensor.py | 1 - tests/components/homewizard/test_init.py | 103 ++++++++++++++++++ 4 files changed, 159 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 036f6c077da..35b303a62e3 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,12 +1,61 @@ """The Homewizard integration.""" from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, PLATFORMS +from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator as Coordinator +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate old entry. + + The HWE-SKT had no total_power_*_kwh in 2023.11, in 2023.12 it does. + But simultaneously, the total_power_*_t1_kwh was removed for HWE-SKT. + + This migration migrates the old unique_id to the new one, if possible. + + Migration can be removed after 2024.6 + """ + entity_registry = er.async_get(hass) + + @callback + def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: + replacements = { + "total_power_import_t1_kwh": "total_power_import_kwh", + "total_power_export_t1_kwh": "total_power_export_kwh", + } + + for old_id, new_id in replacements.items(): + if entry.unique_id.endswith(old_id): + new_unique_id = entry.unique_id.replace(old_id, new_id) + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + LOGGER.debug( + "Cannot migrate to unique_id '%s', already exists for '%s'", + new_unique_id, + existing_entity_id, + ) + return None + LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + return { + "new_unique_id": new_unique_id, + } + + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Homewizard from a config entry.""" coordinator = Coordinator(hass) @@ -21,6 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise + await _async_migrate_entries(hass, entry) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator # Abort reauth config flow if active diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index ff065592283..d4692ee8bf0 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +import logging from homewizard_energy.models import Data, Device, State, System @@ -11,6 +12,8 @@ from homeassistant.const import Platform DOMAIN = "homewizard" PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +LOGGER = logging.getLogger(__package__) + # Platform config. CONF_API_ENABLED = "api_enabled" CONF_DATA = "data" diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 78cee9ee6fe..d980e66e0e4 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -436,7 +436,6 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( HomeWizardSensorEntity(coordinator, description) for description in SENSORS diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 7dab8cfbb06..a4893c77f42 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -7,7 +7,9 @@ import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -118,3 +120,104 @@ async def test_load_handles_homewizardenergy_exception( ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_ERROR, ) + + +@pytest.mark.parametrize( + ("device_fixture", "old_unique_id", "new_unique_id"), + [ + ( + "HWE-SKT", + "aabbccddeeff_total_power_import_t1_kwh", + "aabbccddeeff_total_power_import_kwh", + ), + ( + "HWE-SKT", + "aabbccddeeff_total_power_export_t1_kwh", + "aabbccddeeff_total_power_export_kwh", + ), + ], +) +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_sensor_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test total power T1 sensors are migrated.""" + mock_config_entry.add_to_hass(hass) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=mock_config_entry, + ) + + assert entity.unique_id == old_unique_id + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == new_unique_id + assert entity_migrated.previous_unique_id == old_unique_id + + +@pytest.mark.parametrize( + ("device_fixture", "old_unique_id", "new_unique_id"), + [ + ( + "HWE-SKT", + "aabbccddeeff_total_power_import_t1_kwh", + "aabbccddeeff_total_power_import_kwh", + ), + ( + "HWE-SKT", + "aabbccddeeff_total_power_export_t1_kwh", + "aabbccddeeff_total_power_export_kwh", + ), + ], +) +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_sensor_migration_does_not_trigger( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test total power T1 sensors are not migrated when not possible.""" + mock_config_entry.add_to_hass(hass) + + old_entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=mock_config_entry, + ) + + new_entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=new_unique_id, + config_entry=mock_config_entry, + ) + + assert old_entity.unique_id == old_unique_id + assert new_entity.unique_id == new_unique_id + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = entity_registry.async_get(old_entity.entity_id) + assert entity + assert entity.unique_id == old_unique_id + assert entity.previous_unique_id is None + + entity = entity_registry.async_get(new_entity.entity_id) + assert entity + assert entity.unique_id == new_unique_id + assert entity.previous_unique_id is None From 0eb7034f89b48dca07c827428a75e918da0c6887 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 8 Dec 2023 10:59:38 +0100 Subject: [PATCH 589/643] Adjust stale bot policy for PRs (#105280) --- .github/workflows/stale.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 61dfef074a7..b51550767b8 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,16 +11,16 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - # The 90 day stale policy for PRs + # The 60 day stale policy for PRs # Used for: # - PRs # - No PRs marked as no-stale # - No issues (-1) - - name: 90 days stale PRs policy + - name: 60 days stale PRs policy uses: actions/stale@v9.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-stale: 90 + days-before-stale: 60 days-before-close: 7 days-before-issue-stale: -1 days-before-issue-close: -1 @@ -33,7 +33,11 @@ jobs: pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days. - Thank you for your contributions. + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. + + Thank you for your contribution! # Generate a token for the GitHub App, we use this method to avoid # hitting API limits for our GitHub actions + have a higher rate limit. From 9fb9ff4f902705e7c5dee12f45f594604f1f9ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Fri, 8 Dec 2023 11:46:27 +0100 Subject: [PATCH 590/643] Remove workaround for default lock code in Matter (#105173) * Matter: Remove workaround for default lock code * Review * Review 2 --- homeassistant/components/matter/lock.py | 15 +++------------ tests/components/matter/test_door_lock.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index 8491f58e387..dd29638f765 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -89,10 +89,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.LockDoor(code_bytes) @@ -100,10 +97,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None if self.supports_unbolt: # if the lock reports it has separate unbolt support, @@ -119,10 +113,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py index a9753824edc..991d23f3353 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -14,6 +14,7 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er from .common import set_node_attribute, trigger_subscription_callback @@ -101,6 +102,7 @@ async def test_lock_requires_pin( hass: HomeAssistant, matter_client: MagicMock, door_lock: MatterNode, + entity_registry: er.EntityRegistry, ) -> None: """Test door lock with PINCode.""" @@ -137,6 +139,26 @@ async def test_lock_requires_pin( timed_request_timeout_ms=1000, ) + # Lock door using default code + default_code = "7654321" + entity_registry.async_update_entity_options( + "lock.mock_door_lock", "lock", {"default_code": default_code} + ) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + "lock", + "lock", + {"entity_id": "lock.mock_door_lock"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.LockDoor(default_code.encode()), + timed_request_timeout_ms=1000, + ) + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) From 949ca6bafcb632d147529aa7e0666a26fb559b9f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 8 Dec 2023 12:11:59 +0100 Subject: [PATCH 591/643] Update yarl to 1.9.4 (#105282) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bb997222625..1874807b892 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -57,7 +57,7 @@ ulid-transform==0.9.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 -yarl==1.9.2 +yarl==1.9.4 zeroconf==0.128.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 844fac7142f..d7764864168 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dependencies = [ "ulid-transform==0.9.0", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", - "yarl==1.9.2", + "yarl==1.9.4", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index aa9a0ab0e5a..1b5b8d63c54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,4 +32,4 @@ typing-extensions>=4.8.0,<5.0 ulid-transform==0.9.0 voluptuous==0.13.1 voluptuous-serialize==2.6.0 -yarl==1.9.2 +yarl==1.9.4 From 88ddc2512972423f36447c555ba568fd6e90ed73 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Fri, 8 Dec 2023 06:40:09 -0500 Subject: [PATCH 592/643] Replace apcaccess dependency with aioapcaccess in apcupsd (#104571) * Replace apcaccess dependency with async version aioapcaccess * Upgrade the dependency to the latest version (v0.4.2) * Handle asyncio.IncompleteReadError --- .../components/apcupsd/coordinator.py | 11 +++----- .../components/apcupsd/manifest.json | 2 +- requirements_all.txt | 6 ++--- requirements_test_all.txt | 6 ++--- tests/components/apcupsd/__init__.py | 5 +--- tests/components/apcupsd/test_config_flow.py | 22 ++++++---------- tests/components/apcupsd/test_init.py | 23 ++++++++--------- tests/components/apcupsd/test_sensor.py | 25 ++++++------------- 8 files changed, 38 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index ae4c94a9382..98d464ec526 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -7,7 +7,7 @@ from datetime import timedelta import logging from typing import Final -from apcaccess import status +import aioapcaccess from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -90,13 +90,8 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): Note that the result dict uses upper case for each resource, where our integration uses lower cases as keys internally. """ - async with asyncio.timeout(10): try: - raw = await self.hass.async_add_executor_job( - status.get, self._host, self._port - ) - result: OrderedDict[str, str] = status.parse(raw) - return result - except OSError as error: + return await aioapcaccess.request_status(self._host, self._port) + except (OSError, asyncio.IncompleteReadError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index 55b66f0c0a0..b20e0c8aacf 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["apcaccess"], "quality_scale": "silver", - "requirements": ["apcaccess==0.0.13"] + "requirements": ["aioapcaccess==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6d1c95e88b3..7271bca2a16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -196,6 +196,9 @@ aioairzone==0.6.9 # homeassistant.components.ambient_station aioambient==2023.04.0 +# homeassistant.components.apcupsd +aioapcaccess==0.4.2 + # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -433,9 +436,6 @@ anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 -# homeassistant.components.apcupsd -apcaccess==0.0.13 - # homeassistant.components.weatherkit apple_weatherkit==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 857581cdf2e..e634e36c4b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -175,6 +175,9 @@ aioairzone==0.6.9 # homeassistant.components.ambient_station aioambient==2023.04.0 +# homeassistant.components.apcupsd +aioapcaccess==0.4.2 + # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -397,9 +400,6 @@ anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 -# homeassistant.components.apcupsd -apcaccess==0.0.13 - # homeassistant.components.weatherkit apple_weatherkit==1.1.2 diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index b0eee051331..4c4e0af8705 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -95,10 +95,7 @@ async def async_init_integration( entry.add_to_hass(hass) - with ( - patch("apcaccess.status.parse", return_value=status), - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value=status): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 48d57890320..6a69d4e974e 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -24,7 +24,7 @@ def _patch_setup(): async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: """Test config flow setup with connection error.""" - with patch("apcaccess.status.get") as mock_get: + with patch("aioapcaccess.request_status") as mock_get: mock_get.side_effect = OSError() result = await hass.config_entries.flow.async_init( @@ -38,10 +38,7 @@ async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: async def test_config_flow_no_status(hass: HomeAssistant) -> None: """Test config flow setup with successful connection but no status is reported.""" - with ( - patch("apcaccess.status.parse", return_value={}), # Returns no status. - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value={}): # Returns no status. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -64,11 +61,10 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: mock_entry.add_to_hass(hass) with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status") as mock_request_status, _patch_setup(), ): - mock_parse.return_value = MOCK_STATUS + mock_request_status.return_value = MOCK_STATUS # Now, create the integration again using the same config data, we should reject # the creation due same host / port. @@ -98,7 +94,7 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: # Now we change the serial number and add it again. This should be successful. another_device_status = copy(MOCK_STATUS) another_device_status["SERIALNO"] = MOCK_STATUS["SERIALNO"] + "ZZZ" - mock_parse.return_value = another_device_status + mock_request_status.return_value = another_device_status result = await hass.config_entries.flow.async_init( DOMAIN, @@ -112,8 +108,7 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: async def test_flow_works(hass: HomeAssistant) -> None: """Test successful creation of config entries via user configuration.""" with ( - patch("apcaccess.status.parse", return_value=MOCK_STATUS), - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status", return_value=MOCK_STATUS), _patch_setup() as mock_setup, ): result = await hass.config_entries.flow.async_init( @@ -152,12 +147,11 @@ async def test_flow_minimal_status( integration will vary. """ with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status") as mock_request_status, _patch_setup() as mock_setup, ): status = MOCK_MINIMAL_STATUS | extra_status - mock_parse.return_value = status + mock_request_status.return_value = status result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 756fa07f120..c65efe25bb9 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -1,4 +1,5 @@ """Test init of APCUPSd integration.""" +import asyncio from collections import OrderedDict from unittest.mock import patch @@ -97,7 +98,11 @@ async def test_multiple_integrations(hass: HomeAssistant) -> None: assert state1.state != state2.state -async def test_connection_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "error", + (OSError(), asyncio.IncompleteReadError(partial=b"", expected=0)), +) +async def test_connection_error(hass: HomeAssistant, error: Exception) -> None: """Test connection error during integration setup.""" entry = MockConfigEntry( version=1, @@ -109,10 +114,7 @@ async def test_connection_error(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - with ( - patch("apcaccess.status.parse", side_effect=OSError()), - patch("apcaccess.status.get"), - ): + with patch("aioapcaccess.request_status", side_effect=error): await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -156,12 +158,9 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state != STATE_UNAVAILABLE assert pytest.approx(float(state.state)) == 14.0 - with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status") as mock_request_status: # Mock a network error and then trigger an auto-polling event. - mock_parse.side_effect = OSError() + mock_request_status.side_effect = OSError() future = utcnow() + UPDATE_INTERVAL async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -172,8 +171,8 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE # Reset the API to return a new status and update. - mock_parse.side_effect = None - mock_parse.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} + mock_request_status.side_effect = None + mock_request_status.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} future = future + UPDATE_INTERVAL async_fire_time_changed(hass, future) await hass.async_block_till_done() diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index bff1b858216..24aae1d3937 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -127,10 +127,7 @@ async def test_state_update(hass: HomeAssistant) -> None: assert state.state == "14.0" new_status = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} - with ( - patch("apcaccess.status.parse", return_value=new_status), - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value=new_status): future = utcnow() + timedelta(minutes=2) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -154,11 +151,8 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: # Setup HASS for calling the update_entity service. await async_setup_component(hass, "homeassistant", {}) - with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b"") as mock_get, - ): - mock_parse.return_value = MOCK_STATUS | { + with patch("aioapcaccess.request_status") as mock_request_status: + mock_request_status.return_value = MOCK_STATUS | { "LOADPCT": "15.0 Percent", "BCHARGE": "99.0 Percent", } @@ -174,8 +168,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: ) # Even if we requested updates for two entities, our integration should smartly # group the API calls to just one. - assert mock_parse.call_count == 1 - assert mock_get.call_count == 1 + assert mock_request_status.call_count == 1 # The new state should be effective. state = hass.states.get("sensor.ups_load") @@ -194,10 +187,9 @@ async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: # Setup HASS for calling the update_entity service. await async_setup_component(hass, "homeassistant", {}) - with ( - patch("apcaccess.status.parse", return_value=MOCK_STATUS) as mock_parse, - patch("apcaccess.status.get", return_value=b"") as mock_get, - ): + with patch( + "aioapcaccess.request_status", return_value=MOCK_STATUS + ) as mock_request_status: # Fast-forward time to just pass the initial debouncer cooldown. future = utcnow() + timedelta(seconds=REQUEST_REFRESH_COOLDOWN) async_fire_time_changed(hass, future) @@ -207,5 +199,4 @@ async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: ["sensor.ups_load", "sensor.ups_input_voltage"]}, blocking=True, ) - assert mock_parse.call_count == 1 - assert mock_get.call_count == 1 + assert mock_request_status.call_count == 1 From 8721327176306f3c4604725f6de2b5bedd1ac2e1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Dec 2023 14:35:01 +0100 Subject: [PATCH 593/643] Fix CI test_invalid_rrule_fix test by freezing the time (#105294) --- tests/components/google/test_calendar.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index a70cd8aee9f..8466f5ad4eb 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1301,6 +1301,7 @@ async def test_event_differs_timezone( } +@pytest.mark.freeze_time("2023-11-30 12:15:00 +00:00") async def test_invalid_rrule_fix( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 6b7dace8d4fd1c2003c57847a054499790236dd9 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 8 Dec 2023 14:37:02 +0100 Subject: [PATCH 594/643] Migrate samsungtv tests to use freezegun (#105286) --- .../components/samsungtv/test_media_player.py | 184 +++++++++++------- 1 file changed, 109 insertions(+), 75 deletions(-) diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 674dea752a0..27a06ef3a13 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -11,6 +11,7 @@ from async_upnp_client.exceptions import ( UpnpError, UpnpResponseError, ) +from freezegun.api import FrozenDateTimeFactory import pytest from samsungctl import exceptions from samsungtvws.async_remote import SamsungTVWSAsyncRemote @@ -165,7 +166,9 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") -async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_setup_websocket_2( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Test setup of platform from config entry.""" entity_id = f"{DOMAIN}.fake" @@ -194,9 +197,9 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non assert config_entries[0].data[CONF_MAC] == "aa:bb:ww:ii:ff:ii" next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state @@ -205,7 +208,7 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non @pytest.mark.usefixtures("rest_api") async def test_setup_encrypted_websocket( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Test setup of platform from config entry.""" with patch( @@ -219,9 +222,9 @@ async def test_setup_encrypted_websocket( await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state @@ -229,21 +232,25 @@ async def test_setup_encrypted_websocket( @pytest.mark.usefixtures("remote") -async def test_update_on(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_on( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv on.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @pytest.mark.usefixtures("remote") -async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_off( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -252,16 +259,20 @@ async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: side_effect=[OSError("Boom"), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE async def test_update_off_ws_no_power_state( - hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -276,9 +287,9 @@ async def test_update_off_ws_no_power_state( remotews.is_alive.return_value = False next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -287,7 +298,11 @@ async def test_update_off_ws_no_power_state( @pytest.mark.usefixtures("remotews") async def test_update_off_ws_with_power_state( - hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" with patch.object( @@ -308,9 +323,9 @@ async def test_update_off_ws_with_power_state( device_info["device"]["PowerState"] = "on" rest_api.rest_device_info.return_value = device_info next_update = mock_now + timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() remotews.start_listening.assert_called_once() rest_api.rest_device_info.assert_called_once() @@ -324,9 +339,9 @@ async def test_update_off_ws_with_power_state( # Second update uses device_info(ON) rest_api.rest_device_info.reset_mock() next_update = mock_now + timedelta(minutes=2) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() rest_api.rest_device_info.assert_called_once() @@ -337,9 +352,9 @@ async def test_update_off_ws_with_power_state( rest_api.rest_device_info.reset_mock() device_info["device"]["PowerState"] = "off" next_update = mock_now + timedelta(minutes=3) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() rest_api.rest_device_info.assert_called_once() @@ -350,7 +365,11 @@ async def test_update_off_ws_with_power_state( async def test_update_off_encryptedws( - hass: HomeAssistant, remoteencws: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remoteencws: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) @@ -364,9 +383,9 @@ async def test_update_off_encryptedws( remoteencws.is_alive.return_value = False next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -374,7 +393,9 @@ async def test_update_off_encryptedws( @pytest.mark.usefixtures("remote") -async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_access_denied( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv access denied exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -383,13 +404,14 @@ async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> side_effect=exceptions.AccessDenied("Boom"), ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() assert [ flow @@ -403,6 +425,7 @@ async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_failure( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_now: datetime, remotews: Mock, caplog: pytest.LogCaptureFixture, @@ -416,8 +439,8 @@ async def test_update_ws_connection_failure( side_effect=ConnectionFailure('{"event": "ms.voiceApp.hide"}'), ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert ( @@ -432,7 +455,10 @@ async def test_update_ws_connection_failure( @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_closed( - hass: HomeAssistant, mock_now: datetime, remotews: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_now: datetime, + remotews: Mock, ) -> None: """Testing update tv connection failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -441,8 +467,8 @@ async def test_update_ws_connection_closed( remotews, "start_listening", side_effect=ConnectionClosedError(None, None) ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -451,7 +477,10 @@ async def test_update_ws_connection_closed( @pytest.mark.usefixtures("rest_api") async def test_update_ws_unauthorized_error( - hass: HomeAssistant, mock_now: datetime, remotews: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_now: datetime, + remotews: Mock, ) -> None: """Testing update tv unauthorized failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -460,8 +489,8 @@ async def test_update_ws_unauthorized_error( remotews, "start_listening", side_effect=UnauthorizedError ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert [ @@ -475,7 +504,7 @@ async def test_update_ws_unauthorized_error( @pytest.mark.usefixtures("remote") async def test_update_unhandled_response( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Testing update tv unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -485,9 +514,9 @@ async def test_update_unhandled_response( side_effect=[exceptions.UnhandledResponse("Boom"), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -495,7 +524,7 @@ async def test_update_unhandled_response( @pytest.mark.usefixtures("remote") async def test_connection_closed_during_update_can_recover( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Testing update tv connection closed exception can recover.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -505,17 +534,17 @@ async def test_connection_closed_during_update_can_recover( side_effect=[exceptions.ConnectionClosed(), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -653,7 +682,7 @@ async def test_name(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("remote") -async def test_state(hass: HomeAssistant) -> None: +async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test for state property.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) await hass.services.async_call( @@ -672,7 +701,8 @@ async def test_state(hass: HomeAssistant) -> None: with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError, - ), patch("homeassistant.util.dt.utcnow", return_value=next_update): + ): + freezer.move_to(next_update) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() @@ -1393,7 +1423,11 @@ async def test_upnp_subscribe_events_upnpresponseerror( @pytest.mark.usefixtures("rest_api", "upnp_notify_server") async def test_upnp_re_subscribe_events( - hass: HomeAssistant, remotews: Mock, dmr_device: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + dmr_device: Mock, + mock_now: datetime, ) -> None: """Test for Upnp event feedback.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1407,9 +1441,9 @@ async def test_upnp_re_subscribe_events( remotews, "start_listening", side_effect=WebSocketException("Boom") ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -1417,9 +1451,9 @@ async def test_upnp_re_subscribe_events( assert dmr_device.async_unsubscribe_services.call_count == 1 next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -1434,6 +1468,7 @@ async def test_upnp_re_subscribe_events( ) async def test_upnp_failed_re_subscribe_events( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, remotews: Mock, dmr_device: Mock, mock_now: datetime, @@ -1452,9 +1487,9 @@ async def test_upnp_failed_re_subscribe_events( remotews, "start_listening", side_effect=WebSocketException("Boom") ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -1462,9 +1497,8 @@ async def test_upnp_failed_re_subscribe_events( assert dmr_device.async_unsubscribe_services.call_count == 1 next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch.object( - dmr_device, "async_subscribe_services", side_effect=error - ): + with patch.object(dmr_device, "async_subscribe_services", side_effect=error): + freezer.move_to(next_update) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() From a10f5808155e100bbbea3e2e257453aaddb3dc13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Fri, 8 Dec 2023 14:42:42 +0100 Subject: [PATCH 595/643] =?UTF-8?q?Always=20set=20=5Fattr=5Fcurrent=5Fopti?= =?UTF-8?q?on=20in=20Nob=C3=B8=20Hub=20select=20entities=20(#105289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always set _attr_current_option in select entities. --- homeassistant/components/nobo_hub/select.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index b386e158420..2708dd75ffe 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -59,7 +59,7 @@ class NoboGlobalSelector(SelectEntity): nobo.API.OVERRIDE_MODE_ECO: "eco", } _attr_options = list(_modes.values()) - _attr_current_option: str + _attr_current_option: str | None = None def __init__(self, hub: nobo, override_type) -> None: """Initialize the global override selector.""" @@ -117,7 +117,7 @@ class NoboProfileSelector(SelectEntity): _attr_should_poll = False _profiles: dict[int, str] = {} _attr_options: list[str] = [] - _attr_current_option: str + _attr_current_option: str | None = None def __init__(self, zone_id: str, hub: nobo) -> None: """Initialize the week profile selector.""" From 45f7ffb34c4d77bc98155adfc20ec5fbe736e7eb Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 8 Dec 2023 09:51:19 -0500 Subject: [PATCH 596/643] Add support for accessing Squeezebox over over https (#95088) * Supports access to squeezebox server behind https reverse proxy * Update squeezebox test * Update homeassistant/components/squeezebox/config_flow.py Co-authored-by: Robert Resch * Update homeassistant/components/squeezebox/config_flow.py Co-authored-by: Robert Resch * Update squeezebox unit tests based on code review * Migration unit test * Run black on suggestions accepted in code review * Apply suggestions from code review Instead of upgrading squeezebox config, just assume a default of https=False. Co-authored-by: Erik Montnemery * Update test_init.py Remove migrate entry test since we are no longer migrating * Delete tests/components/squeezebox/test_init.py Remove unused test --------- Co-authored-by: Robert Resch Co-authored-by: Erik Montnemery --- .../components/squeezebox/config_flow.py | 11 +++++++++-- homeassistant/components/squeezebox/const.py | 1 + .../components/squeezebox/manifest.json | 2 +- .../components/squeezebox/media_player.py | 4 +++- .../components/squeezebox/strings.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/squeezebox/test_config_flow.py | 19 +++++++++++++++---- 8 files changed, 33 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 2c96046b97c..b155c7eddc0 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_registry import async_get -from .const import DEFAULT_PORT, DOMAIN +from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -49,9 +49,15 @@ def _base_schema(discovery_info=None): ) else: base_schema.update({vol.Required(CONF_PORT, default=DEFAULT_PORT): int}) + base_schema.update( - {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} + { + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_HTTPS, default=False): bool, + } ) + return vol.Schema(base_schema) @@ -105,6 +111,7 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data[CONF_PORT], data.get(CONF_USERNAME), data.get(CONF_PASSWORD), + https=data[CONF_HTTPS], ) try: diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index d8b67504397..38a9ef7668f 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -6,3 +6,4 @@ PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" DISCOVERY_TASK = "discovery_task" DEFAULT_PORT = 9000 SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:") +CONF_HTTPS = "https" diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 43c2868dd69..83ca3ff1b00 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.6.3"] + "requirements": ["pysqueezebox==0.7.1"] } diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 03457c6a5c0..4e3d71eca24 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -52,6 +52,7 @@ from .browse_media import ( media_source_content_filter, ) from .const import ( + CONF_HTTPS, DISCOVERY_TASK, DOMAIN, KNOWN_PLAYERS, @@ -126,6 +127,7 @@ async def async_setup_entry( password = config.get(CONF_PASSWORD) host = config[CONF_HOST] port = config[CONF_PORT] + https = config.get(CONF_HTTPS, False) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN].setdefault(config_entry.entry_id, {}) @@ -134,7 +136,7 @@ async def async_setup_entry( session = async_get_clientsession(hass) _LOGGER.debug("Creating LMS object for %s", host) - lms = Server(session, host, port, username, password) + lms = Server(session, host, port, username, password, https=https) async def _discovery(now=None): """Discover squeezebox players by polling server.""" diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 756235ae247..fd232851e8a 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -16,7 +16,8 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "https": "Connect over https (requires reverse proxy)" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 7271bca2a16..df49d4e91c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2098,7 +2098,7 @@ pysoma==0.0.12 pyspcwebgw==0.7.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.3 +pysqueezebox==0.7.1 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e634e36c4b4..465df14d425 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ pysoma==0.0.12 pyspcwebgw==0.7.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.3 +pysqueezebox==0.7.1 # homeassistant.components.switchbee pyswitchbee==1.8.0 diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index 87ba2d3be73..f12c7750cdf 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -6,7 +6,7 @@ from pysqueezebox import Server from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.squeezebox.const import DOMAIN +from homeassistant.components.squeezebox.const import CONF_HTTPS, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -59,7 +59,13 @@ async def test_user_form(hass: HomeAssistant) -> None: # test the edit step result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: HOST, CONF_PORT: PORT, CONF_USERNAME: "", CONF_PASSWORD: ""}, + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == HOST @@ -68,6 +74,7 @@ async def test_user_form(hass: HomeAssistant) -> None: CONF_PORT: PORT, CONF_USERNAME: "", CONF_PASSWORD: "", + CONF_HTTPS: False, } await hass.async_block_till_done() @@ -107,7 +114,11 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: "homeassistant.components.squeezebox.async_setup_entry", return_value=True, ): - entry = MockConfigEntry(domain=DOMAIN, unique_id=UUID) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UUID, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False}, + ) await hass.config_entries.async_add(entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -186,7 +197,7 @@ async def test_discovery_no_uuid(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False}, ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "edit" From 05f67f745b5a12b75a517b4c19c42bc87f640f3e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 8 Dec 2023 16:45:34 +0100 Subject: [PATCH 597/643] Update frontend to 20231208.2 (#105299) --- 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 af2ea6f9149..2a7ef1396d5 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==20231206.0"] + "requirements": ["home-assistant-frontend==20231208.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1874807b892..0a44f93324b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ habluetooth==0.10.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231206.0 +home-assistant-frontend==20231208.2 home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index df49d4e91c4..650451e0f46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1024,7 +1024,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231206.0 +home-assistant-frontend==20231208.2 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 465df14d425..7204bf9d40e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -811,7 +811,7 @@ hole==0.8.0 holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231206.0 +home-assistant-frontend==20231208.2 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From b0ef9623f931a840e6b616b39a748ee11b7a8463 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 8 Dec 2023 16:51:07 +0100 Subject: [PATCH 598/643] Add test for energy cost sensor for late price sensor (#105312) --- tests/components/energy/test_sensor.py | 108 +++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index f4a1f661f9b..fe3e6d08653 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -877,6 +877,114 @@ async def test_cost_sensor_handle_price_units( assert state.state == "20.0" +async def test_cost_sensor_handle_late_price_sensor( + setup_integration, + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test energy cost where the price sensor is not immediately available.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + } + price_attributes = { + ATTR_UNIT_OF_MEASUREMENT: f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": "sensor.energy_price", + "number_energy_price": None, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + # Initial state: 10kWh, price sensor not yet available + hass.states.async_set("sensor.energy_price", "unknown", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 10, + energy_attributes, + ) + + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "unknown" + + # Energy use bumped by 10 kWh, price sensor still not yet available + hass.states.async_set( + "sensor.energy_consumption", + 20, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "unknown" + + # Energy use bumped by 10 kWh, price sensor now available + hass.states.async_set("sensor.energy_price", "1", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 30, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Energy use bumped by 10 kWh, price sensor available + hass.states.async_set( + "sensor.energy_consumption", + 40, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "10.0" + + # Energy use bumped by 10 kWh, price sensor no longer available + hass.states.async_set("sensor.energy_price", "unknown", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 50, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "10.0" + + # Energy use bumped by 10 kWh, price sensor again available + hass.states.async_set("sensor.energy_price", "2", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 60, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "50.0" + + @pytest.mark.parametrize( "unit", (UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), From e5a115ce1fff52da7dec0566e4928a18f2486322 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Dec 2023 16:54:02 +0100 Subject: [PATCH 599/643] Fix mqtt json light state updates using deprecated color handling (#105283) --- .../components/mqtt/light/schema_json.py | 3 + tests/components/mqtt/test_light_json.py | 87 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 8702069eab7..3479f1611d8 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -406,6 +406,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): values["color_temp"], self.entity_id, ) + # Allow to switch back to color_temp + if "color" not in values: + self._attr_hs_color = None if self.supported_features and LightEntityFeature.EFFECT: with suppress(KeyError): diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 82b0b3467f4..c5c24c3ae79 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -725,6 +725,93 @@ async def test_controlling_state_via_topic2( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light_rgb/set", + "state_topic": "test_light_rgb/set", + "rgb": True, + "color_temp": True, + "brightness": True, + } + } + } + ], +) +async def test_controlling_the_state_with_legacy_color_handling( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test state updates for lights with a legacy color handling.""" + supported_color_modes = ["color_temp", "hs"] + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_mode") is None + assert state.attributes.get("color_temp") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") is None + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("supported_color_modes") == supported_color_modes + assert state.attributes.get("xy_color") is None + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + for _ in range(0, 2): + # Returned state after the light was turned on + # Receiving legacy color mode: rgb. + async_fire_mqtt_message( + hass, + "test_light_rgb/set", + '{ "state": "ON", "brightness": 255, "level": 100, "hue": 16,' + '"saturation": 100, "color": { "r": 255, "g": 67, "b": 0 }, ' + '"bulb_mode": "color", "color_mode": "rgb" }', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_mode") == "hs" + assert state.attributes.get("color_temp") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") == (15.765, 100.0) + assert state.attributes.get("rgb_color") == (255, 67, 0) + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("xy_color") == (0.674, 0.322) + + # Returned state after the lights color mode was changed + # Receiving legacy color mode: color_temp + async_fire_mqtt_message( + hass, + "test_light_rgb/set", + '{ "state": "ON", "brightness": 255, "level": 100, ' + '"kelvin": 92, "color_temp": 353, "bulb_mode": "white", ' + '"color_mode": "color_temp" }', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_mode") == "color_temp" + assert state.attributes.get("color_temp") == 353 + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") == (28.125, 61.661) + assert state.attributes.get("rgb_color") == (255, 171, 97) + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("xy_color") == (0.513, 0.386) + + @pytest.mark.parametrize( "hass_config", [ From fdeb9e36c38f8ef278111472f14c37cf69a430f9 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 8 Dec 2023 10:05:21 -0600 Subject: [PATCH 600/643] Use area id for context instead of name (#105313) --- .../components/conversation/default_agent.py | 2 +- tests/components/conversation/test_default_agent.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 99ebb4b60b1..aae8f67e1d8 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -649,7 +649,7 @@ class DefaultAgent(AbstractConversationAgent): if device_area is None: return None - return {"area": device_area.name} + return {"area": device_area.id} def _get_error_text( self, response_type: ResponseType, lang_intents: LanguageIntents | None diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index fe94e2d5425..c68ec301280 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -307,8 +307,8 @@ async def test_device_area_context( turn_on_calls = async_mock_service(hass, "light", "turn_on") turn_off_calls = async_mock_service(hass, "light", "turn_off") - area_kitchen = area_registry.async_get_or_create("kitchen") - area_bedroom = area_registry.async_get_or_create("bedroom") + area_kitchen = area_registry.async_get_or_create("Kitchen") + area_bedroom = area_registry.async_get_or_create("Bedroom") # Create 2 lights in each area area_lights = defaultdict(list) @@ -323,7 +323,7 @@ async def test_device_area_context( "off", attributes={ATTR_FRIENDLY_NAME: f"{area.name} light {i}"}, ) - area_lights[area.name].append(light_entity) + area_lights[area.id].append(light_entity) # Create voice satellites in each area entry = MockConfigEntry() @@ -354,6 +354,8 @@ async def test_device_area_context( ) await hass.async_block_till_done() assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_kitchen.id # Verify only kitchen lights were targeted assert {s.entity_id for s in result.response.matched_states} == { @@ -375,6 +377,8 @@ async def test_device_area_context( ) await hass.async_block_till_done() assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_bedroom.id # Verify only bedroom lights were targeted assert {s.entity_id for s in result.response.matched_states} == { @@ -396,6 +400,8 @@ async def test_device_area_context( ) await hass.async_block_till_done() assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_bedroom.id # Verify only bedroom lights were targeted assert {s.entity_id for s in result.response.matched_states} == { From 1c7bd3f7297623bbe8d4f6a0cadd946863b41c30 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Fri, 8 Dec 2023 11:17:42 -0500 Subject: [PATCH 601/643] Add A. O. Smith integration (#104976) --- CODEOWNERS | 2 + homeassistant/components/aosmith/__init__.py | 53 +++++++ .../components/aosmith/config_flow.py | 61 +++++++ homeassistant/components/aosmith/const.py | 16 ++ .../components/aosmith/coordinator.py | 48 ++++++ homeassistant/components/aosmith/entity.py | 51 ++++++ .../components/aosmith/manifest.json | 10 ++ homeassistant/components/aosmith/strings.json | 20 +++ .../components/aosmith/water_heater.py | 149 ++++++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/aosmith/__init__.py | 1 + tests/components/aosmith/conftest.py | 74 +++++++++ .../aosmith/fixtures/get_devices.json | 46 ++++++ .../fixtures/get_devices_mode_pending.json | 46 ++++++ .../get_devices_no_vacation_mode.json | 42 +++++ .../get_devices_setpoint_pending.json | 46 ++++++ .../aosmith/snapshots/test_device.ambr | 29 ++++ .../aosmith/snapshots/test_water_heater.ambr | 27 ++++ tests/components/aosmith/test_config_flow.py | 84 ++++++++++ tests/components/aosmith/test_device.py | 23 +++ tests/components/aosmith/test_init.py | 71 +++++++++ tests/components/aosmith/test_water_heater.py | 147 +++++++++++++++++ 25 files changed, 1059 insertions(+) create mode 100644 homeassistant/components/aosmith/__init__.py create mode 100644 homeassistant/components/aosmith/config_flow.py create mode 100644 homeassistant/components/aosmith/const.py create mode 100644 homeassistant/components/aosmith/coordinator.py create mode 100644 homeassistant/components/aosmith/entity.py create mode 100644 homeassistant/components/aosmith/manifest.json create mode 100644 homeassistant/components/aosmith/strings.json create mode 100644 homeassistant/components/aosmith/water_heater.py create mode 100644 tests/components/aosmith/__init__.py create mode 100644 tests/components/aosmith/conftest.py create mode 100644 tests/components/aosmith/fixtures/get_devices.json create mode 100644 tests/components/aosmith/fixtures/get_devices_mode_pending.json create mode 100644 tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json create mode 100644 tests/components/aosmith/fixtures/get_devices_setpoint_pending.json create mode 100644 tests/components/aosmith/snapshots/test_device.ambr create mode 100644 tests/components/aosmith/snapshots/test_water_heater.ambr create mode 100644 tests/components/aosmith/test_config_flow.py create mode 100644 tests/components/aosmith/test_device.py create mode 100644 tests/components/aosmith/test_init.py create mode 100644 tests/components/aosmith/test_water_heater.py diff --git a/CODEOWNERS b/CODEOWNERS index 9bcc3daac17..d6e4cc764db 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -86,6 +86,8 @@ build.json @home-assistant/supervisor /tests/components/anova/ @Lash-L /homeassistant/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex +/homeassistant/components/aosmith/ @bdr99 +/tests/components/aosmith/ @bdr99 /homeassistant/components/apache_kafka/ @bachya /tests/components/apache_kafka/ @bachya /homeassistant/components/apcupsd/ @yuxincs diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py new file mode 100644 index 00000000000..af780e012ae --- /dev/null +++ b/homeassistant/components/aosmith/__init__.py @@ -0,0 +1,53 @@ +"""The A. O. Smith integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from py_aosmith import AOSmithAPIClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .coordinator import AOSmithCoordinator + +PLATFORMS: list[Platform] = [Platform.WATER_HEATER] + + +@dataclass +class AOSmithData: + """Data for the A. O. Smith integration.""" + + coordinator: AOSmithCoordinator + client: AOSmithAPIClient + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up A. O. Smith from a config entry.""" + email = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + + session = aiohttp_client.async_get_clientsession(hass) + client = AOSmithAPIClient(email, password, session) + coordinator = AOSmithCoordinator(hass, client) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AOSmithData( + coordinator=coordinator, client=client + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/aosmith/config_flow.py b/homeassistant/components/aosmith/config_flow.py new file mode 100644 index 00000000000..4ee29897070 --- /dev/null +++ b/homeassistant/components/aosmith/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for A. O. Smith integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from py_aosmith import AOSmithAPIClient, AOSmithInvalidCredentialsException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for A. O. Smith.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + unique_id = user_input[CONF_EMAIL].lower() + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + session = aiohttp_client.async_get_clientsession(self.hass) + client = AOSmithAPIClient( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session + ) + + try: + await client.get_devices() + except AOSmithInvalidCredentialsException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/aosmith/const.py b/homeassistant/components/aosmith/const.py new file mode 100644 index 00000000000..06794582258 --- /dev/null +++ b/homeassistant/components/aosmith/const.py @@ -0,0 +1,16 @@ +"""Constants for the A. O. Smith integration.""" + +from datetime import timedelta + +DOMAIN = "aosmith" + +AOSMITH_MODE_ELECTRIC = "ELECTRIC" +AOSMITH_MODE_HEAT_PUMP = "HEAT_PUMP" +AOSMITH_MODE_HYBRID = "HYBRID" +AOSMITH_MODE_VACATION = "VACATION" + +# Update interval to be used for normal background updates. +REGULAR_INTERVAL = timedelta(seconds=30) + +# Update interval to be used while a mode or setpoint change is in progress. +FAST_INTERVAL = timedelta(seconds=1) diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py new file mode 100644 index 00000000000..80cf85bc59a --- /dev/null +++ b/homeassistant/components/aosmith/coordinator.py @@ -0,0 +1,48 @@ +"""The data update coordinator for the A. O. Smith integration.""" + +import logging +from typing import Any + +from py_aosmith import ( + AOSmithAPIClient, + AOSmithInvalidCredentialsException, + AOSmithUnknownException, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, FAST_INTERVAL, REGULAR_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class AOSmithCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Custom data update coordinator for A. O. Smith integration.""" + + def __init__(self, hass: HomeAssistant, client: AOSmithAPIClient) -> None: + """Initialize the coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=REGULAR_INTERVAL) + self.client = client + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch latest data from API.""" + try: + devices = await self.client.get_devices() + except (AOSmithInvalidCredentialsException, AOSmithUnknownException) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + mode_pending = any( + device.get("data", {}).get("modePending") for device in devices + ) + setpoint_pending = any( + device.get("data", {}).get("temperatureSetpointPending") + for device in devices + ) + + if mode_pending or setpoint_pending: + self.update_interval = FAST_INTERVAL + else: + self.update_interval = REGULAR_INTERVAL + + return {device.get("junctionId"): device for device in devices} diff --git a/homeassistant/components/aosmith/entity.py b/homeassistant/components/aosmith/entity.py new file mode 100644 index 00000000000..20061ca36b9 --- /dev/null +++ b/homeassistant/components/aosmith/entity.py @@ -0,0 +1,51 @@ +"""The base entity for the A. O. Smith integration.""" + + +from py_aosmith import AOSmithAPIClient + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AOSmithCoordinator + + +class AOSmithEntity(CoordinatorEntity[AOSmithCoordinator]): + """Base entity for A. O. Smith.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AOSmithCoordinator, junction_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.junction_id = junction_id + self._attr_device_info = DeviceInfo( + manufacturer="A. O. Smith", + name=self.device.get("name"), + model=self.device.get("model"), + serial_number=self.device.get("serial"), + suggested_area=self.device.get("install", {}).get("location"), + identifiers={(DOMAIN, junction_id)}, + sw_version=self.device.get("data", {}).get("firmwareVersion"), + ) + + @property + def device(self): + """Shortcut to get the device status from the coordinator data.""" + return self.coordinator.data.get(self.junction_id) + + @property + def device_data(self): + """Shortcut to get the device data within the device status.""" + device = self.device + return None if device is None else device.get("data", {}) + + @property + def client(self) -> AOSmithAPIClient: + """Shortcut to get the API client.""" + return self.coordinator.client + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.device_data.get("isOnline") is True diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json new file mode 100644 index 00000000000..2e3a459d7e2 --- /dev/null +++ b/homeassistant/components/aosmith/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "aosmith", + "name": "A. O. Smith", + "codeowners": ["@bdr99"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aosmith", + "iot_class": "cloud_polling", + "quality_scale": "platinum", + "requirements": ["py-aosmith==1.0.1"] +} diff --git a/homeassistant/components/aosmith/strings.json b/homeassistant/components/aosmith/strings.json new file mode 100644 index 00000000000..157895e04f8 --- /dev/null +++ b/homeassistant/components/aosmith/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Please enter your A. O. Smith credentials." + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py new file mode 100644 index 00000000000..8002373573f --- /dev/null +++ b/homeassistant/components/aosmith/water_heater.py @@ -0,0 +1,149 @@ +"""The water heater platform for the A. O. Smith integration.""" + +from typing import Any + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AOSmithData +from .const import ( + AOSMITH_MODE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP, + AOSMITH_MODE_HYBRID, + AOSMITH_MODE_VACATION, + DOMAIN, +) +from .coordinator import AOSmithCoordinator +from .entity import AOSmithEntity + +MODE_HA_TO_AOSMITH = { + STATE_OFF: AOSMITH_MODE_VACATION, + STATE_ECO: AOSMITH_MODE_HYBRID, + STATE_ELECTRIC: AOSMITH_MODE_ELECTRIC, + STATE_HEAT_PUMP: AOSMITH_MODE_HEAT_PUMP, +} +MODE_AOSMITH_TO_HA = { + AOSMITH_MODE_ELECTRIC: STATE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP: STATE_HEAT_PUMP, + AOSMITH_MODE_HYBRID: STATE_ECO, + AOSMITH_MODE_VACATION: STATE_OFF, +} + +# Operation mode to use when exiting away mode +DEFAULT_OPERATION_MODE = AOSMITH_MODE_HYBRID + +DEFAULT_SUPPORT_FLAGS = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up A. O. Smith water heater platform.""" + data: AOSmithData = hass.data[DOMAIN][entry.entry_id] + + entities = [] + + for junction_id in data.coordinator.data: + entities.append(AOSmithWaterHeaterEntity(data.coordinator, junction_id)) + + async_add_entities(entities) + + +class AOSmithWaterHeaterEntity(AOSmithEntity, WaterHeaterEntity): + """The water heater entity for the A. O. Smith integration.""" + + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_min_temp = 95 + + def __init__(self, coordinator: AOSmithCoordinator, junction_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self._attr_unique_id = junction_id + + @property + def operation_list(self) -> list[str]: + """Return the list of supported operation modes.""" + op_modes = [] + for mode_dict in self.device_data.get("modes", []): + mode_name = mode_dict.get("mode") + ha_mode = MODE_AOSMITH_TO_HA.get(mode_name) + + # Filtering out STATE_OFF since it is handled by away mode + if ha_mode is not None and ha_mode != STATE_OFF: + op_modes.append(ha_mode) + + return op_modes + + @property + def supported_features(self) -> WaterHeaterEntityFeature: + """Return the list of supported features.""" + supports_vacation_mode = any( + mode_dict.get("mode") == AOSMITH_MODE_VACATION + for mode_dict in self.device_data.get("modes", []) + ) + + if supports_vacation_mode: + return DEFAULT_SUPPORT_FLAGS | WaterHeaterEntityFeature.AWAY_MODE + + return DEFAULT_SUPPORT_FLAGS + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.device_data.get("temperatureSetpoint") + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device_data.get("temperatureSetpointMaximum") + + @property + def current_operation(self) -> str: + """Return the current operation mode.""" + return MODE_AOSMITH_TO_HA.get(self.device_data.get("mode"), STATE_OFF) + + @property + def is_away_mode_on(self): + """Return True if away mode is on.""" + return self.device_data.get("mode") == AOSMITH_MODE_VACATION + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + aosmith_mode = MODE_HA_TO_AOSMITH.get(operation_mode) + if aosmith_mode is not None: + await self.client.update_mode(self.junction_id, aosmith_mode) + + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get("temperature") + await self.client.update_setpoint(self.junction_id, temperature) + + await self.coordinator.async_request_refresh() + + async def async_turn_away_mode_on(self) -> None: + """Turn away mode on.""" + await self.client.update_mode(self.junction_id, AOSMITH_MODE_VACATION) + + await self.coordinator.async_request_refresh() + + async def async_turn_away_mode_off(self) -> None: + """Turn away mode off.""" + await self.client.update_mode(self.junction_id, DEFAULT_OPERATION_MODE) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6315d2db46e..1b620f9018b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -46,6 +46,7 @@ FLOWS = { "androidtv_remote", "anova", "anthemav", + "aosmith", "apcupsd", "apple_tv", "aranet", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 652fc11411e..9fc28e59ee2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -286,6 +286,12 @@ "integration_type": "virtual", "supported_by": "energyzero" }, + "aosmith": { + "name": "A. O. Smith", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "apache_kafka": { "name": "Apache Kafka", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 650451e0f46..0f59fe43782 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1534,6 +1534,9 @@ pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==2.1.1 +# homeassistant.components.aosmith +py-aosmith==1.0.1 + # homeassistant.components.canary py-canary==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7204bf9d40e..3bf05465daf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1177,6 +1177,9 @@ pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==2.1.1 +# homeassistant.components.aosmith +py-aosmith==1.0.1 + # homeassistant.components.canary py-canary==0.5.3 diff --git a/tests/components/aosmith/__init__.py b/tests/components/aosmith/__init__.py new file mode 100644 index 00000000000..89845dda42e --- /dev/null +++ b/tests/components/aosmith/__init__.py @@ -0,0 +1 @@ +"""Tests for the A. O. Smith integration.""" diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py new file mode 100644 index 00000000000..509e15024a9 --- /dev/null +++ b/tests/components/aosmith/conftest.py @@ -0,0 +1,74 @@ +"""Common fixtures for the A. O. Smith tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from py_aosmith import AOSmithAPIClient +import pytest + +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import MockConfigEntry, load_json_array_fixture + +FIXTURE_USER_INPUT = { + CONF_EMAIL: "testemail@example.com", + CONF_PASSWORD: "test-password", +} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=FIXTURE_USER_INPUT, + unique_id="unique_id", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aosmith.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def get_devices_fixture() -> str: + """Return the name of the fixture to use for get_devices.""" + return "get_devices" + + +@pytest.fixture +async def mock_client(get_devices_fixture: str) -> Generator[MagicMock, None, None]: + """Return a mocked client.""" + get_devices_fixture = load_json_array_fixture(f"{get_devices_fixture}.json", DOMAIN) + + client_mock = MagicMock(AOSmithAPIClient) + client_mock.get_devices = AsyncMock(return_value=get_devices_fixture) + + return client_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> MockConfigEntry: + """Set up the integration for testing.""" + hass.config.units = US_CUSTOMARY_SYSTEM + + with patch( + "homeassistant.components.aosmith.AOSmithAPIClient", return_value=mock_client + ): + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/aosmith/fixtures/get_devices.json b/tests/components/aosmith/fixtures/get_devices.json new file mode 100644 index 00000000000..e34c50cd270 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_mode_pending.json b/tests/components/aosmith/fixtures/get_devices_mode_pending.json new file mode 100644 index 00000000000..a12f1d95f13 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_mode_pending.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": true, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json b/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json new file mode 100644 index 00000000000..249024e1f1e --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json @@ -0,0 +1,42 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json b/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json new file mode 100644 index 00000000000..4d6e7613cf2 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": true, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr new file mode 100644 index 00000000000..fb80dc06917 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'basement', + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'aosmith', + 'junctionId', + ), + }), + 'is_new': False, + 'manufacturer': 'A. O. Smith', + 'model': 'HPTS-50 200 202172000', + 'name': 'My water heater', + 'name_by_user': None, + 'serial_number': 'serial', + 'suggested_area': 'Basement', + 'sw_version': '2.14', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/aosmith/snapshots/test_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..2293a6c7b65 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_water_heater.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': None, + 'friendly_name': 'My water heater', + 'max_temp': 130, + 'min_temp': 95, + 'operation_list': list([ + 'eco', + 'heat_pump', + 'electric', + ]), + 'operation_mode': 'heat_pump', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 130, + }), + 'context': , + 'entity_id': 'water_heater.my_water_heater', + 'last_changed': , + 'last_updated': , + 'state': 'heat_pump', + }) +# --- diff --git a/tests/components/aosmith/test_config_flow.py b/tests/components/aosmith/test_config_flow.py new file mode 100644 index 00000000000..ff09f23ccbb --- /dev/null +++ b/tests/components/aosmith/test_config_flow.py @@ -0,0 +1,84 @@ +"""Test the A. O. Smith config flow.""" +from unittest.mock import AsyncMock, patch + +from py_aosmith import AOSmithInvalidCredentialsException +import pytest + +from homeassistant import config_entries +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.const import CONF_EMAIL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.components.aosmith.conftest import FIXTURE_USER_INPUT + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] + assert result2["data"] == FIXTURE_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_error_key"), + [ + (AOSmithInvalidCredentialsException("Invalid credentials"), "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_form_exception( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error_key: str, +) -> None: + """Test handling an exception and then recovering on the second attempt.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": expected_error_key} + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] + assert result3["data"] == FIXTURE_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/aosmith/test_device.py b/tests/components/aosmith/test_device.py new file mode 100644 index 00000000000..596f380290e --- /dev/null +++ b/tests/components/aosmith/test_device.py @@ -0,0 +1,23 @@ +"""Tests for the device created by the A. O. Smith integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test creation of the device.""" + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "junctionId")}, + ) + + assert reg_device == snapshot diff --git a/tests/components/aosmith/test_init.py b/tests/components/aosmith/test_init.py new file mode 100644 index 00000000000..8ab699e6f1c --- /dev/null +++ b/tests/components/aosmith/test_init.py @@ -0,0 +1,71 @@ +"""Tests for the initialization of the A. O. Smith integration.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from py_aosmith import AOSmithUnknownException +import pytest + +from homeassistant.components.aosmith.const import ( + DOMAIN, + FAST_INTERVAL, + REGULAR_INTERVAL, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_config_entry_setup(init_integration: MockConfigEntry) -> None: + """Test setup of the config entry.""" + mock_config_entry = init_integration + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the config entry not ready.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=AOSmithUnknownException("Unknown error"), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("get_devices_fixture", "time_to_wait", "expected_call_count"), + [ + ("get_devices", REGULAR_INTERVAL, 1), + ("get_devices", FAST_INTERVAL, 0), + ("get_devices_mode_pending", FAST_INTERVAL, 1), + ("get_devices_setpoint_pending", FAST_INTERVAL, 1), + ], +) +async def test_update( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + time_to_wait: timedelta, + expected_call_count: int, +) -> None: + """Test data update with differing intervals depending on device status.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert mock_client.get_devices.call_count == 1 + + freezer.tick(time_to_wait) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_client.get_devices.call_count == 1 + expected_call_count diff --git a/tests/components/aosmith/test_water_heater.py b/tests/components/aosmith/test_water_heater.py new file mode 100644 index 00000000000..61cb159c82a --- /dev/null +++ b/tests/components/aosmith/test_water_heater.py @@ -0,0 +1,147 @@ +"""Tests for the water heater platform of the A. O. Smith integration.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aosmith.const import ( + AOSMITH_MODE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP, + AOSMITH_MODE_HYBRID, + AOSMITH_MODE_VACATION, +) +from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, + ATTR_OPERATION_MODE, + ATTR_TEMPERATURE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + WaterHeaterEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test the setup of the water heater entity.""" + entry = entity_registry.async_get("water_heater.my_water_heater") + assert entry + assert entry.unique_id == "junctionId" + + state = hass.states.get("water_heater.my_water_heater") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My water heater" + + +async def test_state( + hass: HomeAssistant, init_integration: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: + """Test the state of the water heater entity.""" + state = hass.states.get("water_heater.my_water_heater") + assert state == snapshot + + +@pytest.mark.parametrize( + ("get_devices_fixture"), + ["get_devices_no_vacation_mode"], +) +async def test_state_away_mode_unsupported( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test that away mode is not supported if the water heater does not support vacation mode.""" + state = hass.states.get("water_heater.my_water_heater") + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + +@pytest.mark.parametrize( + ("hass_mode", "aosmith_mode"), + [ + (STATE_HEAT_PUMP, AOSMITH_MODE_HEAT_PUMP), + (STATE_ECO, AOSMITH_MODE_HYBRID), + (STATE_ELECTRIC, AOSMITH_MODE_ELECTRIC), + ], +) +async def test_set_operation_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + hass_mode: str, + aosmith_mode: str, +) -> None: + """Test setting the operation mode.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_OPERATION_MODE: hass_mode, + }, + ) + await hass.async_block_till_done() + + mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) + + +async def test_set_temperature( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test setting the target temperature.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "water_heater.my_water_heater", ATTR_TEMPERATURE: 120}, + ) + await hass.async_block_till_done() + + mock_client.update_setpoint.assert_called_once_with("junctionId", 120) + + +@pytest.mark.parametrize( + ("hass_away_mode", "aosmith_mode"), + [ + (True, AOSMITH_MODE_VACATION), + (False, AOSMITH_MODE_HYBRID), + ], +) +async def test_away_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + hass_away_mode: bool, + aosmith_mode: str, +) -> None: + """Test turning away mode on/off.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_AWAY_MODE: hass_away_mode, + }, + ) + await hass.async_block_till_done() + + mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) From 46d626280e884632b63fdce1b8601be91eb31842 Mon Sep 17 00:00:00 2001 From: Excentyl <35411484+Excentyl@users.noreply.github.com> Date: Fri, 8 Dec 2023 16:46:08 +0000 Subject: [PATCH 602/643] Initialize energy_state without price (#97031) Co-authored-by: Erik --- homeassistant/components/energy/sensor.py | 5 +++++ tests/components/energy/test_sensor.py | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index e9760a96aa4..834a9bbb1eb 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -317,6 +317,11 @@ class EnergyCostSensor(SensorEntity): try: energy_price = float(energy_price_state.state) except ValueError: + if self._last_energy_sensor_state is None: + # Initialize as it's the first time all required entities except + # price are in place. This means that the cost will update the first + # time the energy is updated after the price entity is in place. + self._reset(energy_state) return energy_price_unit: str | None = energy_price_state.attributes.get( diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index fe3e6d08653..522bbe5af06 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -924,7 +924,7 @@ async def test_cost_sensor_handle_late_price_sensor( await setup_integration(hass) state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "unknown" + assert state.state == "0.0" # Energy use bumped by 10 kWh, price sensor still not yet available hass.states.async_set( @@ -935,7 +935,7 @@ async def test_cost_sensor_handle_late_price_sensor( await hass.async_block_till_done() state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "unknown" + assert state.state == "0.0" # Energy use bumped by 10 kWh, price sensor now available hass.states.async_set("sensor.energy_price", "1", price_attributes) @@ -947,7 +947,7 @@ async def test_cost_sensor_handle_late_price_sensor( await hass.async_block_till_done() state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "0.0" + assert state.state == "20.0" # Energy use bumped by 10 kWh, price sensor available hass.states.async_set( @@ -958,7 +958,7 @@ async def test_cost_sensor_handle_late_price_sensor( await hass.async_block_till_done() state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "10.0" + assert state.state == "30.0" # Energy use bumped by 10 kWh, price sensor no longer available hass.states.async_set("sensor.energy_price", "unknown", price_attributes) @@ -970,7 +970,7 @@ async def test_cost_sensor_handle_late_price_sensor( await hass.async_block_till_done() state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "10.0" + assert state.state == "30.0" # Energy use bumped by 10 kWh, price sensor again available hass.states.async_set("sensor.energy_price", "2", price_attributes) @@ -982,7 +982,7 @@ async def test_cost_sensor_handle_late_price_sensor( await hass.async_block_till_done() state = hass.states.get("sensor.energy_consumption_cost") - assert state.state == "50.0" + assert state.state == "70.0" @pytest.mark.parametrize( From af715a4b9a21a7c855afc67bec005d0e977c8987 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 8 Dec 2023 18:13:34 +0100 Subject: [PATCH 603/643] Add workaround for orjson not handling subclasses of str (#105314) Co-authored-by: Franck Nijhof --- homeassistant/util/json.py | 14 +++++++++++--- tests/util/test_json.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index ac18d43727c..1af35c604eb 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -33,9 +33,17 @@ class SerializationError(HomeAssistantError): """Error serializing the data to JSON.""" -json_loads: Callable[[bytes | bytearray | memoryview | str], JsonValueType] -json_loads = orjson.loads -"""Parse JSON data.""" +def json_loads(__obj: bytes | bytearray | memoryview | str) -> JsonValueType: + """Parse JSON data. + + This adds a workaround for orjson not handling subclasses of str, + https://github.com/ijl/orjson/issues/445. + """ + if type(__obj) in (bytes, bytearray, memoryview, str): + return orjson.loads(__obj) # type:ignore[no-any-return] + if isinstance(__obj, str): + return orjson.loads(str(__obj)) # type:ignore[no-any-return] + return orjson.loads(__obj) # type:ignore[no-any-return] def json_loads_array(__obj: bytes | bytearray | memoryview | str) -> JsonArrayType: diff --git a/tests/util/test_json.py b/tests/util/test_json.py index b3bccf71b58..ff0f1ed8392 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -1,10 +1,12 @@ """Test Home Assistant json utility functions.""" from pathlib import Path +import orjson import pytest from homeassistant.exceptions import HomeAssistantError from homeassistant.util.json import ( + json_loads, json_loads_array, json_loads_object, load_json, @@ -153,3 +155,20 @@ async def test_deprecated_save_json( save_json(fname, TEST_JSON_A) assert "uses save_json from homeassistant.util.json" in caplog.text assert "should be updated to use homeassistant.helpers.json module" in caplog.text + + +async def test_loading_derived_class(): + """Test loading data from classes derived from str.""" + + class MyStr(str): + pass + + class MyBytes(bytes): + pass + + assert json_loads('"abc"') == "abc" + assert json_loads(MyStr('"abc"')) == "abc" + + assert json_loads(b'"abc"') == "abc" + with pytest.raises(orjson.JSONDecodeError): + assert json_loads(MyBytes(b'"abc"')) == "abc" From 4bb0e13cda023c02819b4966544dc41ba38ec83c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Dec 2023 20:57:53 +0100 Subject: [PATCH 604/643] Workaround `to_json` template filter in parsing dict key (#105327) * Work-a-round orjson for `to_json` fiter in case dict key is str subclass * Add option instead * Remove json.dumps work-a-round * Update homeassistant/helpers/template.py * Fix test --------- Co-authored-by: Erik Montnemery --- homeassistant/helpers/template.py | 4 ++++ tests/helpers/test_template.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 721ac8bd5be..df8b1c1e019 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2125,6 +2125,10 @@ def to_json( option = ( ORJSON_PASSTHROUGH_OPTIONS + # OPT_NON_STR_KEYS is added as a workaround to + # ensure subclasses of str are allowed as dict keys + # See: https://github.com/ijl/orjson/issues/445 + | orjson.OPT_NON_STR_KEYS | (orjson.OPT_INDENT_2 if pretty_print else 0) | (orjson.OPT_SORT_KEYS if sort_keys else 0) ) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index d1294d02f05..58d52dfc395 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1233,6 +1233,22 @@ def test_to_json(hass: HomeAssistant) -> None: with pytest.raises(TemplateError): template.Template("{{ {'Foo': now()} | to_json }}", hass).async_render() + # Test special case where substring class cannot be rendered + # See: https://github.com/ijl/orjson/issues/445 + class MyStr(str): + pass + + expected_result = '{"mykey1":11.0,"mykey2":"myvalue2","mykey3":["opt3b","opt3a"]}' + test_dict = { + MyStr("mykey2"): "myvalue2", + MyStr("mykey1"): 11.0, + MyStr("mykey3"): ["opt3b", "opt3a"], + } + actual_result = template.Template( + "{{ test_dict | to_json(sort_keys=True) }}", hass + ).async_render(parse_result=False, variables={"test_dict": test_dict}) + assert actual_result == expected_result + def test_to_json_ensure_ascii(hass: HomeAssistant) -> None: """Test the object to JSON string filter.""" From 99cf4a6b2d99ce576cf2aee8665ac1388e67d82a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 8 Dec 2023 21:13:37 +0100 Subject: [PATCH 605/643] Add rollback on exception that needs rollback in SQL (#104948) --- homeassistant/components/sql/sensor.py | 2 ++ tests/components/sql/test_sensor.py | 48 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 3fdc6b2c079..c4e6db4c623 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -362,6 +362,8 @@ class SQLSensor(ManualTriggerSensorEntity): self._query, redact_credentials(str(err)), ) + sess.rollback() + sess.close() return for res in result.mappings(): diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index cb988d3f2d4..cdc9a8e07a6 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -5,6 +5,7 @@ from datetime import timedelta from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy import text as sql_text from sqlalchemy.exc import SQLAlchemyError @@ -12,6 +13,7 @@ from sqlalchemy.exc import SQLAlchemyError from homeassistant.components.recorder import Recorder from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sql.const import CONF_QUERY, DOMAIN +from homeassistant.components.sql.sensor import _generate_lambda_stmt from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_ICON, @@ -21,6 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -570,3 +573,48 @@ async def test_attributes_from_entry_config( assert state.attributes["unit_of_measurement"] == "MiB" assert "device_class" not in state.attributes assert "state_class" not in state.attributes + + +async def test_query_recover_from_rollback( + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the SQL sensor.""" + config = { + "db_url": "sqlite://", + "query": "SELECT 5 as value", + "column": "value", + "name": "Select value SQL query", + "unique_id": "very_unique_id", + } + await init_integration(hass, config) + platforms = async_get_platforms(hass, "sql") + sql_entity = platforms[0].entities["sensor.select_value_sql_query"] + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes["value"] == 5 + + with patch.object( + sql_entity, + "_lambda_stmt", + _generate_lambda_stmt("Faulty syntax create operational issue"), + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert "sqlite3.OperationalError" in caplog.text + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes.get("value") is None + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes.get("value") == 5 From 46e75ed94be67a6252306c7af4cd14e66ba30cb0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Fri, 8 Dec 2023 21:15:33 +0100 Subject: [PATCH 606/643] Bump plugwise to v0.34.5 (#105330) --- homeassistant/components/plugwise/climate.py | 10 +++++++ .../components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../plugwise/fixtures/adam_jip/all_data.json | 5 ++++ .../fixtures/adam_jip/device_list.json | 13 ++++++++++ .../all_data.json | 1 + .../device_list.json | 20 ++++++++++++++ .../anna_heatpump_heating/device_list.json | 5 ++++ .../fixtures/m_adam_cooling/all_data.json | 3 +++ .../fixtures/m_adam_cooling/device_list.json | 8 ++++++ .../fixtures/m_adam_heating/all_data.json | 26 +++++++++++++++++++ .../fixtures/m_adam_heating/device_list.json | 8 ++++++ .../m_anna_heatpump_cooling/device_list.json | 5 ++++ .../m_anna_heatpump_idle/device_list.json | 5 ++++ .../fixtures/p1v3_full_option/all_data.json | 1 + .../p1v3_full_option/device_list.json | 1 + .../fixtures/p1v4_442_triple/all_data.json | 1 + .../fixtures/p1v4_442_triple/device_list.json | 1 + .../fixtures/stretch_v31/all_data.json | 1 + .../fixtures/stretch_v31/device_list.json | 10 +++++++ .../plugwise/snapshots/test_diagnostics.ambr | 1 + tests/components/plugwise/test_climate.py | 8 +++--- 23 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 tests/components/plugwise/fixtures/adam_jip/device_list.json create mode 100644 tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json create mode 100644 tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json create mode 100644 tests/components/plugwise/fixtures/m_adam_cooling/device_list.json create mode 100644 tests/components/plugwise/fixtures/m_adam_heating/device_list.json create mode 100644 tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json create mode 100644 tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json create mode 100644 tests/components/plugwise/fixtures/p1v3_full_option/device_list.json create mode 100644 tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json create mode 100644 tests/components/plugwise/fixtures/stretch_v31/device_list.json diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index efad1b7466b..84e0619773b 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -160,6 +160,16 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): # Keep track of the previous action-mode self._previous_action_mode(self.coordinator) + # Adam provides the hvac_action for each thermostat + if (control_state := self.device.get("control_state")) == "cooling": + return HVACAction.COOLING + if control_state == "heating": + return HVACAction.HEATING + if control_state == "preheating": + return HVACAction.PREHEATING + if control_state == "off": + return HVACAction.IDLE + heater: str = self.coordinator.data.gateway["heater_id"] heater_data = self.coordinator.data.devices[heater] if heater_data["binary_sensors"]["heating_state"]: diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 1373ba40fa3..bb2b428bf19 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.34.3"], + "requirements": ["plugwise==0.34.5"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0f59fe43782..192d753a8c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1486,7 +1486,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.3 +plugwise==0.34.5 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bf05465daf..cc85c9bc58a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1144,7 +1144,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.3 +plugwise==0.34.5 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json index dacee20c644..37566e1d39e 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -4,6 +4,7 @@ "active_preset": "no_frost", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -99,6 +100,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -155,6 +157,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -265,6 +268,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", "hardware": "1", @@ -300,6 +304,7 @@ "cooling_present": false, "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", "heater_id": "e4684553153b44afbef2200885f379dc", + "item_count": 219, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/adam_jip/device_list.json b/tests/components/plugwise/fixtures/adam_jip/device_list.json new file mode 100644 index 00000000000..049845bc828 --- /dev/null +++ b/tests/components/plugwise/fixtures/adam_jip/device_list.json @@ -0,0 +1,13 @@ +[ + "b5c2386c6f6342669e50fe49dd05b188", + "e4684553153b44afbef2200885f379dc", + "a6abc6a129ee499c88a4d420cc413b47", + "1346fbd8498d4dbcab7e18d51b771f3d", + "833de10f269c4deab58fb9df69901b4e", + "6f3e9d7084214c21b9dfa46f6eeb8700", + "f61f1a2535f54f52ad006a3d18e459ca", + "d4496250d0e942cfa7aea3476e9070d5", + "356b65335e274d769c338223e7af9c33", + "1da4d325838e4ad8aac12177214505c9", + "457ce8414de24596a2d5e7dbc9c7682f" +] diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 6e6da1aa272..279fe6b8a43 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -468,6 +468,7 @@ "cooling_present": false, "gateway_id": "fe799307f1624099878210aa0b9f1475", "heater_id": "90986d591dcd426cae3ec3e8111ff730", + "item_count": 315, "notifications": { "af82e4ccf9c548528166d38e560662a4": { "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json new file mode 100644 index 00000000000..104a723e463 --- /dev/null +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json @@ -0,0 +1,20 @@ +[ + "fe799307f1624099878210aa0b9f1475", + "90986d591dcd426cae3ec3e8111ff730", + "df4a4a8169904cdb9c03d61a21f42140", + "b310b72a0e354bfab43089919b9a88bf", + "a2c3583e0a6349358998b760cea82d2a", + "b59bcebaf94b499ea7d46e4a66fb62d8", + "d3da73bde12a47d5a6b8f9dad971f2ec", + "21f2b542c49845e6bb416884c55778d6", + "78d1126fc4c743db81b61c20e88342a7", + "cd0ddb54ef694e11ac18ed1cbce5dbbd", + "4a810418d5394b3f82727340b91ba740", + "02cf28bfec924855854c544690a609ef", + "a28f588dc4a049a483fd03a30361ad3a", + "6a3bf693d05e48e0b460c815a4fdd09d", + "680423ff840043738f42cc7f1ff97a36", + "f1fee6043d3642a9b0a65297455f008e", + "675416a629f343c495449970e2ca37b5", + "e7693eb9582644e5b865dba8d4447cf1" +] diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json new file mode 100644 index 00000000000..ffb8cf62575 --- /dev/null +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 624547155a3..2e1063d14d3 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -53,6 +53,7 @@ "active_preset": "asleep", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "cooling", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "cool", @@ -102,6 +103,7 @@ "active_preset": "home", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -148,6 +150,7 @@ "cooling_present": true, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", + "item_count": 145, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json b/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json new file mode 100644 index 00000000000..f78b4cd38a9 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json @@ -0,0 +1,8 @@ +[ + "da224107914542988a88561b4452b0f6", + "056ee145a816487eaa69243c3280f8bf", + "ad4838d7d35c4d6ea796ee12ae5aedf8", + "1772a4ea304041adb83f357b751341ff", + "e2f4322d57924fa090fbbc48b3a140dc", + "e8ef2a01ed3b4139a53bf749204fe6b4" +] diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index e8a72c9b3fb..81d60bed9d4 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -1,5 +1,28 @@ { "devices": { + "01234567890abcdefghijklmnopqrstu": { + "available": false, + "dev_class": "thermo_sensor", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "temperature": 18.6, + "temperature_difference": 2.3, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, "056ee145a816487eaa69243c3280f8bf": { "available": true, "binary_sensors": { @@ -58,6 +81,7 @@ "active_preset": "asleep", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "preheating", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat", @@ -101,6 +125,7 @@ "active_preset": "home", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -147,6 +172,7 @@ "cooling_present": false, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", + "item_count": 145, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/m_adam_heating/device_list.json b/tests/components/plugwise/fixtures/m_adam_heating/device_list.json new file mode 100644 index 00000000000..f78b4cd38a9 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_heating/device_list.json @@ -0,0 +1,8 @@ +[ + "da224107914542988a88561b4452b0f6", + "056ee145a816487eaa69243c3280f8bf", + "ad4838d7d35c4d6ea796ee12ae5aedf8", + "1772a4ea304041adb83f357b751341ff", + "e2f4322d57924fa090fbbc48b3a140dc", + "e8ef2a01ed3b4139a53bf749204fe6b4" +] diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json new file mode 100644 index 00000000000..ffb8cf62575 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json new file mode 100644 index 00000000000..ffb8cf62575 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] diff --git a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json index 0e0b3c51a07..0a47893c077 100644 --- a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json +++ b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json @@ -42,6 +42,7 @@ }, "gateway": { "gateway_id": "cd3e822288064775a7c4afcdd70bdda2", + "item_count": 31, "notifications": {}, "smile_name": "Smile P1" } diff --git a/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json b/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json new file mode 100644 index 00000000000..8af35165c7e --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json @@ -0,0 +1 @@ +["cd3e822288064775a7c4afcdd70bdda2", "e950c7d5e1ee407a858e2a8b5016c8b3"] diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json index d503bd3a59d..ecda8049163 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json @@ -51,6 +51,7 @@ }, "gateway": { "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", + "item_count": 40, "notifications": { "97a04c0c263049b29350a660b4cdd01e": { "warning": "The Smile P1 is not connected to a smart meter." diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json b/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json new file mode 100644 index 00000000000..7b301f50924 --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json @@ -0,0 +1 @@ +["03e65b16e4b247a29ae0d75a78cb492e", "b82b6b3322484f2ea4e25e0bd5f3d61f"] diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index 8604aaae10e..6b1012b0d87 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -135,6 +135,7 @@ }, "gateway": { "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", + "item_count": 83, "notifications": {}, "smile_name": "Stretch" } diff --git a/tests/components/plugwise/fixtures/stretch_v31/device_list.json b/tests/components/plugwise/fixtures/stretch_v31/device_list.json new file mode 100644 index 00000000000..b2c839ae9d3 --- /dev/null +++ b/tests/components/plugwise/fixtures/stretch_v31/device_list.json @@ -0,0 +1,10 @@ +[ + "0000aaaa0000aaaa0000aaaa0000aa00", + "5871317346d045bc9f6b987ef25ee638", + "e1c884e7dede431dadee09506ec4f859", + "aac7b735042c4832ac9ff33aae4f453b", + "cfe95cf3de1948c0b8955125bf754614", + "059e4d03c7a34d278add5c7a4a781d19", + "d950b314e9d8499f968e6db8d82ef78c", + "d03738edfcc947f7b8f4573571d90d2d" +] diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 597b9710ec5..29f23a137fb 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -500,6 +500,7 @@ 'cooling_present': False, 'gateway_id': 'fe799307f1624099878210aa0b9f1475', 'heater_id': '90986d591dcd426cae3ec3e8111ff730', + 'item_count': 315, 'notifications': dict({ 'af82e4ccf9c548528166d38e560662a4': dict({ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index c14fd802e3b..c5ab3a209c2 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -65,7 +65,7 @@ async def test_adam_2_climate_entity_attributes( state = hass.states.get("climate.anna") assert state assert state.state == HVACMode.HEAT - assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_action"] == "preheating" assert state.attributes["hvac_modes"] == [ HVACMode.OFF, HVACMode.AUTO, @@ -75,7 +75,7 @@ async def test_adam_2_climate_entity_attributes( state = hass.states.get("climate.lisa_badkamer") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_action"] == "idle" assert state.attributes["hvac_modes"] == [ HVACMode.OFF, HVACMode.AUTO, @@ -101,7 +101,7 @@ async def test_adam_3_climate_entity_attributes( data.devices["da224107914542988a88561b4452b0f6"][ "select_regulation_mode" ] = "heating" - data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["mode"] = "heat" + data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["control_state"] = "heating" data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = False @@ -124,7 +124,7 @@ async def test_adam_3_climate_entity_attributes( data.devices["da224107914542988a88561b4452b0f6"][ "select_regulation_mode" ] = "cooling" - data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["mode"] = "cool" + data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["control_state"] = "cooling" data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = True From a7845406a52b9967c944a158572fa6caf062b8de Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 8 Dec 2023 21:18:09 +0100 Subject: [PATCH 607/643] Migrate homeassistant component tests to use freezegun (#105333) --- .../homeassistant/triggers/test_time.py | 195 +++++----- .../triggers/test_time_pattern.py | 338 +++++++++--------- 2 files changed, 264 insertions(+), 269 deletions(-) diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index b4554f1a4e6..513827b5432 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -38,34 +39,33 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_using_at(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at.""" now = dt_util.now() trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "5:00:00"}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}", - "id": "{{ trigger.id}}", - }, + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "5:00:00"}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}", + "id": "{{ trigger.id}}", }, - } - }, - ) - await hass.async_block_till_done() + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -79,7 +79,7 @@ async def test_if_fires_using_at(hass: HomeAssistant, calls) -> None: ("has_date", "has_time"), [(True, True), (True, False), (False, True)] ) async def test_if_fires_using_at_input_datetime( - hass: HomeAssistant, calls, has_date, has_time + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, has_date, has_time ) -> None: """Test for firing at input_datetime.""" await async_setup_component( @@ -107,24 +107,22 @@ async def test_if_fires_using_at_input_datetime( time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "input_datetime.trigger"}, - "action": { - "service": "test.automation", - "data_template": {"some": some_data}, - }, - } - }, - ) - await hass.async_block_till_done() + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "input_datetime.trigger"}, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -161,7 +159,9 @@ async def test_if_fires_using_at_input_datetime( ) -async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_multiple_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at.""" now = dt_util.now() @@ -169,26 +169,23 @@ async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" - }, + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" }, - } - }, - ) - await hass.async_block_till_done() + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -203,7 +200,9 @@ async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: assert calls[1].data["some"] == "time - 6" -async def test_if_not_fires_using_wrong_at(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_using_wrong_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """YAML translates time values to total seconds. This should break the before rule. @@ -214,25 +213,23 @@ async def test_if_not_fires_using_wrong_at(hass: HomeAssistant, calls) -> None: year=now.year + 1, hour=1, minute=0, second=0 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - with assert_setup_component(1, automation.DOMAIN): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time", - "at": 3605, - # Total seconds. Hour = 3600 second - }, - "action": {"service": "test.automation"}, - } - }, - ) - await hass.async_block_till_done() + freezer.move_to(time_that_will_not_match_right_away) + with assert_setup_component(1, automation.DOMAIN): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": 3605, + # Total seconds. Hour = 3600 second + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE async_fire_time_changed( @@ -409,7 +406,9 @@ async def test_untrack_time_change(hass: HomeAssistant) -> None: assert len(mock_track_time_change.mock_calls) == 3 -async def test_if_fires_using_at_sensor(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_at_sensor( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -424,24 +423,22 @@ async def test_if_fires_using_at_sensor(hass: HomeAssistant, calls) -> None: time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "sensor.next_alarm"}, - "action": { - "service": "test.automation", - "data_template": {"some": some_data}, - }, - } - }, - ) - await hass.async_block_till_done() + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "sensor.next_alarm"}, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index e7a6a98bb96..0f6a075eb6e 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -1,7 +1,7 @@ """The tests for the time_pattern automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -27,33 +27,33 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_when_hour_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_hour_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if hour is matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=3 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 0, - "minutes": "*", - "seconds": "*", - }, - "action": { - "service": "test.automation", - "data_template": {"id": "{{ trigger.id}}"}, - }, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 0, + "minutes": "*", + "seconds": "*", + }, + "action": { + "service": "test.automation", + "data_template": {"id": "{{ trigger.id}}"}, + }, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, hour=0)) await hass.async_block_till_done() @@ -72,30 +72,30 @@ async def test_if_fires_when_hour_matches(hass: HomeAssistant, calls) -> None: assert calls[0].data["id"] == 0 -async def test_if_fires_when_minute_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_minute_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if minutes are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=30 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": 0, - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": 0, + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, minute=0)) @@ -103,30 +103,30 @@ async def test_if_fires_when_minute_matches(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_when_second_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_second_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=30 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": 0, - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": 0, + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, second=0)) @@ -135,31 +135,29 @@ async def test_if_fires_when_second_matches(hass: HomeAssistant, calls) -> None: async def test_if_fires_when_second_as_string_matches( - hass: HomeAssistant, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls ) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=15 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "30", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "30", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, time_that_will_not_match_right_away + timedelta(seconds=15) @@ -169,30 +167,30 @@ async def test_if_fires_when_second_as_string_matches( assert len(calls) == 1 -async def test_if_fires_when_all_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_all_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if everything matches.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=4 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 1, - "minutes": 2, - "seconds": 3, - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 1, + "minutes": 2, + "seconds": 3, + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=1, minute=2, second=3) @@ -202,30 +200,30 @@ async def test_if_fires_when_all_matches(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_periodic_seconds(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_seconds( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every second.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "/10", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "/10", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=0, minute=0, second=10) @@ -235,31 +233,31 @@ async def test_if_fires_periodic_seconds(hass: HomeAssistant, calls) -> None: assert len(calls) >= 1 -async def test_if_fires_periodic_minutes(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_minutes( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every minute.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "/2", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "/2", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=0, minute=2, second=0) @@ -269,30 +267,30 @@ async def test_if_fires_periodic_minutes(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_periodic_hours(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_hours( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "/2", - "minutes": "*", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "/2", + "minutes": "*", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=2, minute=0, second=0) @@ -302,25 +300,25 @@ async def test_if_fires_periodic_hours(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_default_values(hass: HomeAssistant, calls) -> None: +async def test_default_values( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at 2 minutes every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time_pattern", "minutes": "2"}, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time_pattern", "minutes": "2"}, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=1, minute=2, second=0) From 12019ec77c1567d49ad14ba1dc71611085e657e6 Mon Sep 17 00:00:00 2001 From: Viktor Andersson <30777521+VIKTORVAV99@users.noreply.github.com> Date: Fri, 8 Dec 2023 21:22:34 +0100 Subject: [PATCH 608/643] Add myself as code owner for co2signal (#105302) --- CODEOWNERS | 4 ++-- homeassistant/components/co2signal/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index d6e4cc764db..b5ae219bb1b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -207,8 +207,8 @@ build.json @home-assistant/supervisor /tests/components/cloud/ @home-assistant/cloud /homeassistant/components/cloudflare/ @ludeeus @ctalkington /tests/components/cloudflare/ @ludeeus @ctalkington -/homeassistant/components/co2signal/ @jpbede -/tests/components/co2signal/ @jpbede +/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99 +/tests/components/co2signal/ @jpbede @VIKTORVAV99 /homeassistant/components/coinbase/ @tombrien /tests/components/coinbase/ @tombrien /homeassistant/components/color_extractor/ @GenericStudent diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index d82af5b5034..f91232c1a28 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -1,7 +1,7 @@ { "domain": "co2signal", "name": "Electricity Maps", - "codeowners": ["@jpbede"], + "codeowners": ["@jpbede", "@VIKTORVAV99"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/co2signal", "integration_type": "service", From 6a6956573fd534feae497b3773401faddbca7860 Mon Sep 17 00:00:00 2001 From: mkmer Date: Fri, 8 Dec 2023 15:30:41 -0500 Subject: [PATCH 609/643] Add missing configuration for services.yaml in blink (#105310) --- homeassistant/components/blink/services.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index 95f4d33f91f..f6420e7f004 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -8,6 +8,10 @@ trigger_camera: domain: camera save_video: + target: + entity: + integration: blink + domain: camera fields: name: required: true @@ -21,6 +25,10 @@ save_video: text: save_recent_clips: + target: + entity: + integration: blink + domain: camera fields: name: required: true @@ -34,6 +42,10 @@ save_recent_clips: text: send_pin: + target: + entity: + integration: blink + domain: camera fields: pin: example: "abc123" From e1df1f9ffe46953edc2fefe8b1b78b9ebb5a35f1 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 9 Dec 2023 11:15:57 +1000 Subject: [PATCH 610/643] Replace aiohttp mock with patch in Advantage Air (#104932) Co-authored-by: J. Nick Koston --- tests/components/advantage_air/__init__.py | 25 ++- tests/components/advantage_air/conftest.py | 20 ++ .../advantage_air/test_binary_sensor.py | 26 +-- .../components/advantage_air/test_climate.py | 195 +++++++----------- .../advantage_air/test_config_flow.py | 57 +++-- tests/components/advantage_air/test_cover.py | 104 +++------- .../advantage_air/test_diagnostics.py | 12 +- tests/components/advantage_air/test_init.py | 29 +-- tests/components/advantage_air/test_light.py | 99 ++------- tests/components/advantage_air/test_select.py | 36 +--- tests/components/advantage_air/test_sensor.py | 45 +--- tests/components/advantage_air/test_switch.py | 77 ++----- tests/components/advantage_air/test_update.py | 19 +- 13 files changed, 252 insertions(+), 492 deletions(-) create mode 100644 tests/components/advantage_air/conftest.py diff --git a/tests/components/advantage_air/__init__.py b/tests/components/advantage_air/__init__.py index b826e3ac7ce..05d98e957bb 100644 --- a/tests/components/advantage_air/__init__.py +++ b/tests/components/advantage_air/__init__.py @@ -1,12 +1,14 @@ """Tests for the Advantage Air component.""" +from unittest.mock import AsyncMock, patch + from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture -TEST_SYSTEM_DATA = load_fixture("advantage_air/getSystemData.json") -TEST_SET_RESPONSE = load_fixture("advantage_air/setAircon.json") +TEST_SYSTEM_DATA = load_json_object_fixture("getSystemData.json", DOMAIN) +TEST_SET_RESPONSE = None USER_INPUT = { CONF_IP_ADDRESS: "1.2.3.4", @@ -25,6 +27,22 @@ TEST_SET_THING_URL = ( ) +def patch_get(return_value=TEST_SYSTEM_DATA, side_effect=None): + """Patch the Advantage Air async_get method.""" + return patch( + "homeassistant.components.advantage_air.advantage_air.async_get", + new=AsyncMock(return_value=return_value, side_effect=side_effect), + ) + + +def patch_update(return_value=True, side_effect=None): + """Patch the Advantage Air async_set method.""" + return patch( + "homeassistant.components.advantage_air.advantage_air._endpoint.async_update", + new=AsyncMock(return_value=return_value, side_effect=side_effect), + ) + + async def add_mock_config(hass): """Create a fake Advantage Air Config Entry.""" entry = MockConfigEntry( @@ -33,6 +51,7 @@ async def add_mock_config(hass): unique_id="0123456", data=USER_INPUT, ) + entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/advantage_air/conftest.py b/tests/components/advantage_air/conftest.py new file mode 100644 index 00000000000..9da0a176309 --- /dev/null +++ b/tests/components/advantage_air/conftest.py @@ -0,0 +1,20 @@ +"""Fixtures for advantage_air.""" +from __future__ import annotations + +import pytest + +from . import patch_get, patch_update + + +@pytest.fixture +def mock_get(): + """Fixture to patch the Advantage Air async_get method.""" + with patch_get() as mock_get: + yield mock_get + + +@pytest.fixture +def mock_update(): + """Fixture to patch the Advantage Air async_get method.""" + with patch_update() as mock_get: + yield mock_get diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index c6d055f396a..19b0dba2eda 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test the Advantage Air Binary Sensor Platform.""" from datetime import timedelta +from unittest.mock import AsyncMock from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON @@ -7,37 +8,20 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) +from . import add_mock_config from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker async def test_binary_sensor_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, ) -> None: """Test binary sensor setup.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test First Air Filter entity_id = "binary_sensor.myzone_filter" state = hass.states.get(entity_id) @@ -83,6 +67,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -91,6 +76,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state @@ -105,6 +91,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -113,6 +100,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index a1eb886cbd0..ba97644501f 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -1,20 +1,10 @@ """Test the Advantage Air Climate Platform.""" -from json import loads +from unittest.mock import AsyncMock + +from advantage_air import ApiError import pytest -from homeassistant.components.advantage_air.climate import ( - ADVANTAGE_AIR_COOL_TARGET, - ADVANTAGE_AIR_HEAT_TARGET, - HASS_FAN_MODES, - HASS_HVAC_MODES, -) -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_CLOSE, - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, - ADVANTAGE_AIR_STATE_OPEN, -) from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, @@ -37,35 +27,20 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config, patch_update -async def test_climate_async_setup_entry( +async def test_climate_myzone_main( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: - """Test climate platform.""" + """Test climate platform main entity.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - # Test MyZone Climate Entity + # Test MyZone main climate entity entity_id = "climate.myzone" state = hass.states.get(entity_id) assert state @@ -80,19 +55,24 @@ async def test_climate_async_setup_entry( assert entry.unique_id == "uniqueid-ac1" # Test setting HVAC Mode + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + mock_update.assert_called_once() + mock_update.reset_mock() + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON - assert data["ac1"]["info"]["mode"] == HASS_HVAC_MODES[HVACMode.FAN_ONLY] - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning Off with HVAC Mode await hass.services.async_call( @@ -101,26 +81,17 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() - # Test changing Fan Mode await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_FAN_MODE: FAN_LOW}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["fan"] == HASS_FAN_MODES[FAN_LOW] - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test changing Temperature await hass.services.async_call( @@ -129,12 +100,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["setTemp"] == 25 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning On await hass.services.async_call( @@ -143,12 +110,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning Off await hass.services.async_call( @@ -157,12 +120,19 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() + + +async def test_climate_myzone_zone( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, +) -> None: + """Test climate platform myzone zone entity.""" + + await add_mock_config(hass) # Test Climate Zone Entity entity_id = "climate.myzone_zone_open_with_sensor" @@ -184,14 +154,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Climate Zone Off await hass.services.async_call( @@ -200,13 +164,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( CLIMATE_DOMAIN, @@ -214,11 +173,19 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) + mock_update.assert_called_once() + mock_update.reset_mock() - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + +async def test_climate_myauto_main( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, +) -> None: + """Test climate platform zone entity.""" + + await add_mock_config(hass) # Test MyAuto Climate Entity entity_id = "climate.myauto" @@ -231,44 +198,34 @@ async def test_climate_async_setup_entry( assert entry assert entry.unique_id == "uniqueid-ac3" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: [entity_id], - ATTR_TARGET_TEMP_LOW: 21, - ATTR_TARGET_TEMP_HIGH: 23, - }, - blocking=True, - ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["info"][ADVANTAGE_AIR_HEAT_TARGET] == 21 - assert data["ac3"]["info"][ADVANTAGE_AIR_COOL_TARGET] == 23 + with patch_update() as mock_update: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TARGET_TEMP_LOW: 21, + ATTR_TARGET_TEMP_HIGH: 23, + }, + blocking=True, + ) + mock_update.assert_called_once() async def test_climate_async_failed_update( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test climate change failure.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - exc=SyntaxError, - ) - await add_mock_config(hass) - with pytest.raises(HomeAssistantError): + mock_update.side_effect = ApiError + await add_mock_config(hass) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ["climate.myzone"], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/setAircon" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_config_flow.py b/tests/components/advantage_air/test_config_flow.py index fc74df5538b..64d445a0b20 100644 --- a/tests/components/advantage_air/test_config_flow.py +++ b/tests/components/advantage_air/test_config_flow.py @@ -1,23 +1,18 @@ """Test the Advantage Air config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from advantage_air import ApiError from homeassistant import config_entries, data_entry_flow from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, USER_INPUT - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import TEST_SYSTEM_DATA, USER_INPUT -async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_form(hass: HomeAssistant) -> None: """Test that form shows up.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -26,6 +21,9 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> assert result1["errors"] == {} with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(return_value=TEST_SYSTEM_DATA), + ) as mock_get, patch( "homeassistant.components.advantage_air.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -34,43 +32,44 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> USER_INPUT, ) await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + mock_get.assert_called_once() - assert len(aioclient_mock.mock_calls) == 1 assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "testname" assert result2["data"] == USER_INPUT - assert len(mock_setup_entry.mock_calls) == 1 # Test Duplicate Config Flow result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - USER_INPUT, - ) + with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(return_value=TEST_SYSTEM_DATA), + ) as mock_get: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + USER_INPUT, + ) assert result4["type"] == data_entry_flow.FlowResultType.ABORT -async def test_form_cannot_connect( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - exc=SyntaxError, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) + with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(side_effect=ApiError), + ) as mock_get: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + mock_get.assert_called_once() assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "cannot_connect"} - assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/advantage_air/test_cover.py b/tests/components/advantage_air/test_cover.py index af516d16e6e..8166b5da941 100644 --- a/tests/components/advantage_air/test_cover.py +++ b/tests/components/advantage_air/test_cover.py @@ -1,10 +1,6 @@ """Test the Advantage Air Cover Platform.""" -from json import loads +from unittest.mock import AsyncMock -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_CLOSE, - ADVANTAGE_AIR_STATE_OPEN, -) from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN as COVER_DOMAIN, @@ -17,34 +13,17 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OPEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_ac_cover( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test cover platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Cover Zone Entity @@ -65,12 +44,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -78,13 +53,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac3"]["zones"]["z01"]["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -92,12 +62,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 50}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["value"] == 50 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -105,12 +71,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 0}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test controlling multiple Cover Zone Entity await hass.services.async_call( @@ -124,9 +86,9 @@ async def test_ac_cover( }, blocking=True, ) - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert len(mock_update.mock_calls) == 2 + mock_update.reset_mock() + await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, @@ -138,27 +100,18 @@ async def test_ac_cover( }, blocking=True, ) - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_OPEN + + assert len(mock_update.mock_calls) == 2 async def test_things_cover( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test cover platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Blind 1 Entity @@ -171,7 +124,7 @@ async def test_things_cover( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-200" + assert entry.unique_id == f"uniqueid-{thing_id}" await hass.services.async_call( COVER_DOMAIN, @@ -179,13 +132,8 @@ async def test_things_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -193,10 +141,4 @@ async def test_things_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_diagnostics.py b/tests/components/advantage_air/test_diagnostics.py index 01f6d809a49..80de9019715 100644 --- a/tests/components/advantage_air/test_diagnostics.py +++ b/tests/components/advantage_air/test_diagnostics.py @@ -1,28 +1,24 @@ """Test the Advantage Air Diagnostics.""" +from unittest.mock import AsyncMock + from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config +from . import add_mock_config from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator async def test_select_async_setup_entry( hass: HomeAssistant, hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, snapshot: SnapshotAssertion, + mock_get: AsyncMock, ) -> None: """Test select platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - entry = await add_mock_config(hass) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == snapshot diff --git a/tests/components/advantage_air/test_init.py b/tests/components/advantage_air/test_init.py index c665d038878..21cadbc4b3d 100644 --- a/tests/components/advantage_air/test_init.py +++ b/tests/components/advantage_air/test_init.py @@ -1,22 +1,17 @@ """Test the Advantage Air Initialization.""" +from unittest.mock import AsyncMock + +from advantage_air import ApiError + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config, patch_get -async def test_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_async_setup_entry(hass: HomeAssistant, mock_get: AsyncMock) -> None: """Test a successful setup entry and unload.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - entry = await add_mock_config(hass) assert entry.state is ConfigEntryState.LOADED @@ -25,15 +20,9 @@ async def test_async_setup_entry( assert entry.state is ConfigEntryState.NOT_LOADED -async def test_async_setup_entry_failure( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_async_setup_entry_failure(hass: HomeAssistant) -> None: """Test a unsuccessful setup entry.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - exc=SyntaxError, - ) - - entry = await add_mock_config(hass) + with patch_get(side_effect=ApiError): + entry = await add_mock_config(hass) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/advantage_air/test_light.py b/tests/components/advantage_air/test_light.py index 0e27b8aec73..4d21781772d 100644 --- a/tests/components/advantage_air/test_light.py +++ b/tests/components/advantage_air/test_light.py @@ -1,10 +1,8 @@ """Test the Advantage Air Switch Platform.""" -from json import loads -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, -) + +from unittest.mock import AsyncMock + from homeassistant.components.light import ( ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN, @@ -15,34 +13,17 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_LIGHT_URL, - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_light( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test light setup.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_LIGHT_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Light Entity @@ -62,13 +43,9 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -76,13 +53,8 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Dimmable Light Entity entity_id = "light.light_b" @@ -98,13 +70,8 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -112,32 +79,17 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 50 - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() async def test_things_light( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test things lights.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -149,7 +101,7 @@ async def test_things_light( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-204" + assert entry.unique_id == f"uniqueid-{light_id}" await hass.services.async_call( LIGHT_DOMAIN, @@ -157,13 +109,8 @@ async def test_things_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -171,10 +118,4 @@ async def test_things_light( {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 50 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_select.py b/tests/components/advantage_air/test_select.py index 553c2e60180..3367595d777 100644 --- a/tests/components/advantage_air/test_select.py +++ b/tests/components/advantage_air/test_select.py @@ -1,5 +1,7 @@ """Test the Advantage Air Select Platform.""" -from json import loads + + +from unittest.mock import AsyncMock from homeassistant.components.select import ( ATTR_OPTION, @@ -10,37 +12,19 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_select_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test select platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test MyZone Select Entity entity_id = "select.myzone_myzone" state = hass.states.get(entity_id) @@ -57,10 +41,4 @@ async def test_select_async_setup_entry( {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Zone 3"}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["myZone"] == 3 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index e4fab12291d..a7483e680b3 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -1,6 +1,6 @@ """Test the Advantage Air Sensor Platform.""" from datetime import timedelta -from json import loads +from unittest.mock import AsyncMock from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN from homeassistant.components.advantage_air.sensor import ( @@ -13,37 +13,21 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) +from . import add_mock_config from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensor_platform( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test sensor platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test First TimeToOn Sensor entity_id = "sensor.myzone_time_to_on" state = hass.states.get(entity_id) @@ -55,19 +39,15 @@ async def test_sensor_platform( assert entry.unique_id == "uniqueid-ac1-timetoOn" value = 20 + await hass.services.async_call( ADVANTAGE_AIR_DOMAIN, ADVANTAGE_AIR_SERVICE_SET_TIME_TO, {ATTR_ENTITY_ID: [entity_id], ADVANTAGE_AIR_SET_COUNTDOWN_VALUE: value}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["countDownToOn"] == value - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test First TimeToOff Sensor entity_id = "sensor.myzone_time_to_off" @@ -86,13 +66,8 @@ async def test_sensor_platform( {ATTR_ENTITY_ID: [entity_id], ADVANTAGE_AIR_SET_COUNTDOWN_VALUE: value}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 5 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["countDownToOff"] == value - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test First Zone Vent Sensor entity_id = "sensor.myzone_zone_open_with_sensor_vent" @@ -139,6 +114,7 @@ async def test_sensor_platform( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -147,6 +123,7 @@ async def test_sensor_platform( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_switch.py b/tests/components/advantage_air/test_switch.py index 99e4c645e71..a703f7edefd 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -1,10 +1,8 @@ """Test the Advantage Air Switch Platform.""" -from json import loads -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, -) + +from unittest.mock import AsyncMock + from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -14,34 +12,17 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_cover_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test switch platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -60,12 +41,8 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["freshAirStatus"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, @@ -73,30 +50,17 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["freshAirStatus"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() async def test_things_switch( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test things switches.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -108,7 +72,7 @@ async def test_things_switch( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-205" + assert entry.unique_id == f"uniqueid-{thing_id}" await hass.services.async_call( SWITCH_DOMAIN, @@ -116,13 +80,8 @@ async def test_things_switch( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, @@ -130,10 +89,4 @@ async def test_things_switch( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_update.py b/tests/components/advantage_air/test_update.py index 985641b923b..cb180d73f39 100644 --- a/tests/components/advantage_air/test_update.py +++ b/tests/components/advantage_air/test_update.py @@ -1,25 +1,26 @@ """Test the Advantage Air Update Platform.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import TEST_SYSTEM_URL, add_mock_config +from . import add_mock_config -from tests.common import load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import load_json_object_fixture + +TEST_NEEDS_UPDATE = load_json_object_fixture("needsUpdate.json", DOMAIN) async def test_update_platform( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, ) -> None: """Test update platform.""" - - aioclient_mock.get( - TEST_SYSTEM_URL, - text=load_fixture("advantage_air/needsUpdate.json"), - ) + mock_get.return_value = TEST_NEEDS_UPDATE await add_mock_config(hass) entity_id = "update.testname_app" From 617b045727d1da15b241874184d61fd56ab466d7 Mon Sep 17 00:00:00 2001 From: vexofp Date: Sat, 9 Dec 2023 02:34:01 -0500 Subject: [PATCH 611/643] Fix SSLCipherList typing error in IMAP coordinator (#105362) --- homeassistant/components/imap/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 34286ce49fa..5591980b2f1 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -62,7 +62,7 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" ssl_cipher_list: str = data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) if data.get(CONF_VERIFY_SSL, True): - ssl_context = client_context(ssl_cipher_list=ssl_cipher_list) + ssl_context = client_context(ssl_cipher_list=SSLCipherList(ssl_cipher_list)) else: ssl_context = create_no_verify_ssl_context() client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) From 906aa14b43e7ce88663ffdce46fe6aaa6480f417 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 9 Dec 2023 04:01:40 -0400 Subject: [PATCH 612/643] Bump pyschlage to 2023.12.0 (#105349) Co-authored-by: J. Nick Koston --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 1eb7cb2ab0f..e14a5bc706e 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.11.0"] + "requirements": ["pyschlage==2023.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 192d753a8c8..054a3e300e4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2042,7 +2042,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.11.0 +pyschlage==2023.12.0 # homeassistant.components.sensibo pysensibo==1.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc85c9bc58a..b0dd713b162 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1547,7 +1547,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.11.0 +pyschlage==2023.12.0 # homeassistant.components.sensibo pysensibo==1.0.36 From f567bf6dfe0e17e3d926063b28054ba054d7a609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Sat, 9 Dec 2023 05:45:40 -0300 Subject: [PATCH 613/643] Sun WEG integration (#88272) * feat(sunweg): initial support * chore: removed commented out code * chore: removed warning * fix: set never_resets for total sensors * test: some tests * fix(sunweg): default plantid type * fix(sunweg): return first plant id * test(sunweg): improved code coverage * chore(sunweg): missing FlowResult return type * chore(sunweg): removed unused strings * perf(sunweg): using only one api instance * chore(sunweg): removed uneeded atribute * refact(sunweg): small refactoring * refact(sunweg): typing * chore(sunweg): comments * chore(sunweg): bump version * chore(sunweg): bump lib version * test(sunweg): different mocking and coverage * test: fixed setup component parameter * feat: dynamic metrics * fix(sunweg): ruff * fix(sunweg): mypy * refact(sunweg): codereview suggestions * chore(sunweg): removed unused string * chore(sunweg): typehint and code formatting --- CODEOWNERS | 2 + homeassistant/components/sunweg/__init__.py | 193 ++++++++++++++++++ .../components/sunweg/config_flow.py | 74 +++++++ homeassistant/components/sunweg/const.py | 12 ++ homeassistant/components/sunweg/manifest.json | 10 + homeassistant/components/sunweg/sensor.py | 177 ++++++++++++++++ .../sunweg/sensor_types/__init__.py | 1 + .../sunweg/sensor_types/inverter.py | 69 +++++++ .../components/sunweg/sensor_types/phase.py | 26 +++ .../sensor_types/sensor_entity_description.py | 23 +++ .../components/sunweg/sensor_types/string.py | 26 +++ .../components/sunweg/sensor_types/total.py | 54 +++++ homeassistant/components/sunweg/strings.json | 25 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/sunweg/__init__.py | 1 + tests/components/sunweg/common.py | 63 ++++++ tests/components/sunweg/test_config_flow.py | 135 ++++++++++++ tests/components/sunweg/test_init.py | 146 +++++++++++++ 21 files changed, 1050 insertions(+) create mode 100644 homeassistant/components/sunweg/__init__.py create mode 100644 homeassistant/components/sunweg/config_flow.py create mode 100644 homeassistant/components/sunweg/const.py create mode 100644 homeassistant/components/sunweg/manifest.json create mode 100644 homeassistant/components/sunweg/sensor.py create mode 100644 homeassistant/components/sunweg/sensor_types/__init__.py create mode 100644 homeassistant/components/sunweg/sensor_types/inverter.py create mode 100644 homeassistant/components/sunweg/sensor_types/phase.py create mode 100644 homeassistant/components/sunweg/sensor_types/sensor_entity_description.py create mode 100644 homeassistant/components/sunweg/sensor_types/string.py create mode 100644 homeassistant/components/sunweg/sensor_types/total.py create mode 100644 homeassistant/components/sunweg/strings.json create mode 100644 tests/components/sunweg/__init__.py create mode 100644 tests/components/sunweg/common.py create mode 100644 tests/components/sunweg/test_config_flow.py create mode 100644 tests/components/sunweg/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index b5ae219bb1b..1fe8bf68e78 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1253,6 +1253,8 @@ build.json @home-assistant/supervisor /homeassistant/components/suez_water/ @ooii /homeassistant/components/sun/ @Swamp-Ig /tests/components/sun/ @Swamp-Ig +/homeassistant/components/sunweg/ @rokam +/tests/components/sunweg/ @rokam /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen diff --git a/homeassistant/components/sunweg/__init__.py b/homeassistant/components/sunweg/__init__.py new file mode 100644 index 00000000000..f77633f4953 --- /dev/null +++ b/homeassistant/components/sunweg/__init__.py @@ -0,0 +1,193 @@ +"""The Sun WEG inverter sensor integration.""" +import datetime +import json +import logging + +from sunweg.api import APIHelper +from sunweg.plant import Plant + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import StateType +from homeassistant.util import Throttle + +from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS +from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription + +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Load the saved entities.""" + api = APIHelper(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + if not await hass.async_add_executor_job(api.authenticate): + _LOGGER.error("Username or Password may be incorrect!") + return False + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SunWEGData( + api, entry.data[CONF_PLANT_ID] + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hass.data[DOMAIN].pop(entry.entry_id) + if len(hass.data[DOMAIN]) == 0: + hass.data.pop(DOMAIN) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class SunWEGData: + """The class for handling data retrieval.""" + + def __init__( + self, + api: APIHelper, + plant_id: int, + ) -> None: + """Initialize the probe.""" + + self.api = api + self.plant_id = plant_id + self.data: Plant = None + self.previous_values: dict = {} + + @Throttle(SCAN_INTERVAL) + def update(self) -> None: + """Update probe data.""" + _LOGGER.debug("Updating data for plant %s", self.plant_id) + try: + self.data = self.api.plant(self.plant_id) + for inverter in self.data.inverters: + self.api.complete_inverter(inverter) + except json.decoder.JSONDecodeError: + _LOGGER.error("Unable to fetch data from SunWEG server") + _LOGGER.debug("Finished updating data for plant %s", self.plant_id) + + def get_api_value( + self, + variable: str, + device_type: str, + inverter_id: int = 0, + deep_name: str | None = None, + ): + """Retrieve from a Plant the desired variable value.""" + if device_type == "total": + return self.data.__dict__.get(variable) + + inverter_list = [i for i in self.data.inverters if i.id == inverter_id] + if len(inverter_list) == 0: + return None + inverter = inverter_list[0] + + if device_type == "inverter": + return inverter.__dict__.get(variable) + if device_type == "phase": + for phase in inverter.phases: + if phase.name == deep_name: + return phase.__dict__.get(variable) + elif device_type == "string": + for mppt in inverter.mppts: + for string in mppt.strings: + if string.name == deep_name: + return string.__dict__.get(variable) + return None + + def get_data( + self, + entity_description: SunWEGSensorEntityDescription, + device_type: str, + inverter_id: int = 0, + deep_name: str | None = None, + ) -> StateType | datetime.datetime: + """Get the data.""" + _LOGGER.debug( + "Data request for: %s", + entity_description.name, + ) + variable = entity_description.api_variable_key + previous_metric = entity_description.native_unit_of_measurement + api_value = self.get_api_value(variable, device_type, inverter_id, deep_name) + previous_value = self.previous_values.get(variable) + return_value = api_value + if entity_description.api_variable_metric is not None: + entity_description.native_unit_of_measurement = self.get_api_value( + entity_description.api_variable_metric, + device_type, + inverter_id, + deep_name, + ) + + # If we have a 'drop threshold' specified, then check it and correct if needed + if ( + entity_description.previous_value_drop_threshold is not None + and previous_value is not None + and api_value is not None + and previous_metric == entity_description.native_unit_of_measurement + ): + _LOGGER.debug( + ( + "%s - Drop threshold specified (%s), checking for drop... API" + " Value: %s, Previous Value: %s" + ), + entity_description.name, + entity_description.previous_value_drop_threshold, + api_value, + previous_value, + ) + diff = float(api_value) - float(previous_value) + + # Check if the value has dropped (negative value i.e. < 0) and it has only + # dropped by a small amount, if so, use the previous value. + # Note - The energy dashboard takes care of drops within 10% + # of the current value, however if the value is low e.g. 0.2 + # and drops by 0.1 it classes as a reset. + if -(entity_description.previous_value_drop_threshold) <= diff < 0: + _LOGGER.debug( + ( + "Diff is negative, but only by a small amount therefore not a" + " nightly reset, using previous value (%s) instead of api value" + " (%s)" + ), + previous_value, + api_value, + ) + return_value = previous_value + else: + _LOGGER.debug( + "%s - No drop detected, using API value", entity_description.name + ) + + # Lifetime total values should always be increasing, they will never reset, + # however the API sometimes returns 0 values when the clock turns to 00:00 + # local time in that scenario we should just return the previous value + # Scenarios: + # 1 - System has a genuine 0 value when it it first commissioned: + # - will return 0 until a non-zero value is registered + # 2 - System has been running fine but temporarily resets to 0 briefly + # at midnight: + # - will return the previous value + # 3 - HA is restarted during the midnight 'outage' - Not handled: + # - Previous value will not exist meaning 0 will be returned + # - This is an edge case that would be better handled by looking + # up the previous value of the entity from the recorder + if entity_description.never_resets and api_value == 0 and previous_value: + _LOGGER.debug( + ( + "API value is 0, but this value should never reset, returning" + " previous value (%s) instead" + ), + previous_value, + ) + return_value = previous_value + + self.previous_values[variable] = return_value + + return return_value diff --git a/homeassistant/components/sunweg/config_flow.py b/homeassistant/components/sunweg/config_flow.py new file mode 100644 index 00000000000..cd24a4722e9 --- /dev/null +++ b/homeassistant/components/sunweg/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for Sun WEG integration.""" +from sunweg.api import APIHelper +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_PLANT_ID, DOMAIN + + +class SunWEGConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow class.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialise sun weg server flow.""" + self.api: APIHelper = None + self.data: dict = {} + + @callback + def _async_show_user_form(self, errors=None) -> FlowResult: + """Show the form to the user.""" + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle the start of the config flow.""" + if not user_input: + return self._async_show_user_form() + + # Initialise the library with the username & password + self.api = APIHelper(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + login_response = await self.hass.async_add_executor_job(self.api.authenticate) + + if not login_response: + return self._async_show_user_form({"base": "invalid_auth"}) + + # Store authentication info + self.data = user_input + return await self.async_step_plant() + + async def async_step_plant(self, user_input=None) -> FlowResult: + """Handle adding a "plant" to Home Assistant.""" + plant_list = await self.hass.async_add_executor_job(self.api.listPlants) + + if len(plant_list) == 0: + return self.async_abort(reason="no_plants") + + plants = {plant.id: plant.name for plant in plant_list} + + if user_input is None and len(plant_list) > 1: + data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) + + return self.async_show_form(step_id="plant", data_schema=data_schema) + + if user_input is None and len(plant_list) == 1: + user_input = {CONF_PLANT_ID: plant_list[0].id} + + user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] + await self.async_set_unique_id(user_input[CONF_PLANT_ID]) + self._abort_if_unique_id_configured() + self.data.update(user_input) + return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) diff --git a/homeassistant/components/sunweg/const.py b/homeassistant/components/sunweg/const.py new file mode 100644 index 00000000000..12ecfb3849c --- /dev/null +++ b/homeassistant/components/sunweg/const.py @@ -0,0 +1,12 @@ +"""Define constants for the Sun WEG component.""" +from homeassistant.const import Platform + +CONF_PLANT_ID = "plant_id" + +DEFAULT_PLANT_ID = 0 + +DEFAULT_NAME = "Sun WEG" + +DOMAIN = "sunweg" + +PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json new file mode 100644 index 00000000000..271a16236d3 --- /dev/null +++ b/homeassistant/components/sunweg/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sunweg", + "name": "Sun WEG", + "codeowners": ["@rokam"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sunweg/", + "iot_class": "cloud_polling", + "loggers": ["sunweg"], + "requirements": ["sunweg==2.0.0"] +} diff --git a/homeassistant/components/sunweg/sensor.py b/homeassistant/components/sunweg/sensor.py new file mode 100644 index 00000000000..157595219e8 --- /dev/null +++ b/homeassistant/components/sunweg/sensor.py @@ -0,0 +1,177 @@ +"""Read status of SunWEG inverters.""" +from __future__ import annotations + +import datetime +import logging +from types import MappingProxyType +from typing import Any + +from sunweg.api import APIHelper +from sunweg.device import Inverter +from sunweg.plant import Plant + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import SunWEGData +from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN +from .sensor_types.inverter import INVERTER_SENSOR_TYPES +from .sensor_types.phase import PHASE_SENSOR_TYPES +from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription +from .sensor_types.string import STRING_SENSOR_TYPES +from .sensor_types.total import TOTAL_SENSOR_TYPES + +_LOGGER = logging.getLogger(__name__) + + +def get_device_list( + api: APIHelper, config: MappingProxyType[str, Any] +) -> tuple[list[Inverter], int]: + """Retrieve the device list for the selected plant.""" + plant_id = int(config[CONF_PLANT_ID]) + + if plant_id == DEFAULT_PLANT_ID: + plant_info: list[Plant] = api.listPlants() + plant_id = plant_info[0].id + + devices: list[Inverter] = [] + # Get a list of devices for specified plant to add sensors for. + for inverter in api.plant(plant_id).inverters: + api.complete_inverter(inverter) + devices.append(inverter) + return (devices, plant_id) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the SunWEG sensor.""" + name = config_entry.data[CONF_NAME] + + probe: SunWEGData = hass.data[DOMAIN][config_entry.entry_id] + + devices, plant_id = await hass.async_add_executor_job( + get_device_list, probe.api, config_entry.data + ) + + entities = [ + SunWEGInverter( + probe, + name=f"{name} Total", + unique_id=f"{plant_id}-{description.key}", + description=description, + device_type="total", + ) + for description in TOTAL_SENSOR_TYPES + ] + + # Add sensors for each device in the specified plant. + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name}", + unique_id=f"{device.sn}-{description.key}", + description=description, + device_type="inverter", + inverter_id=device.id, + ) + for device in devices + for description in INVERTER_SENSOR_TYPES + ] + ) + + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name} {phase.name}", + unique_id=f"{device.sn}-{phase.name}-{description.key}", + description=description, + inverter_id=device.id, + device_type="phase", + deep_name=phase.name, + ) + for device in devices + for phase in device.phases + for description in PHASE_SENSOR_TYPES + ] + ) + + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name} {string.name}", + unique_id=f"{device.sn}-{string.name}-{description.key}", + description=description, + inverter_id=device.id, + device_type="string", + deep_name=string.name, + ) + for device in devices + for mppt in device.mppts + for string in mppt.strings + for description in STRING_SENSOR_TYPES + ] + ) + + async_add_entities(entities, True) + + +class SunWEGInverter(SensorEntity): + """Representation of a SunWEG Sensor.""" + + entity_description: SunWEGSensorEntityDescription + + def __init__( + self, + probe: SunWEGData, + name: str, + unique_id: str, + description: SunWEGSensorEntityDescription, + device_type: str, + inverter_id: int = 0, + deep_name: str | None = None, + ) -> None: + """Initialize a sensor.""" + self.probe = probe + self.entity_description = description + self.device_type = device_type + self.inverter_id = inverter_id + self.deep_name = deep_name + + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = unique_id + self._attr_icon = ( + description.icon if description.icon is not None else "mdi:solar-power" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(probe.plant_id))}, + manufacturer="SunWEG", + name=name, + ) + + @property + def native_value( + self, + ) -> StateType | datetime.datetime: + """Return the state of the sensor.""" + return self.probe.get_data( + self.entity_description, + device_type=self.device_type, + inverter_id=self.inverter_id, + deep_name=self.deep_name, + ) + + def update(self) -> None: + """Get the latest data from the Sun WEG API and updates the state.""" + self.probe.update() diff --git a/homeassistant/components/sunweg/sensor_types/__init__.py b/homeassistant/components/sunweg/sensor_types/__init__.py new file mode 100644 index 00000000000..f370fddd16b --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/__init__.py @@ -0,0 +1 @@ +"""Sensor types for supported Sun WEG systems.""" diff --git a/homeassistant/components/sunweg/sensor_types/inverter.py b/homeassistant/components/sunweg/sensor_types/inverter.py new file mode 100644 index 00000000000..abb7e224836 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/inverter.py @@ -0,0 +1,69 @@ +"""SunWEG Sensor definitions for the Inverter type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) + +from .sensor_entity_description import SunWEGSensorEntityDescription + +INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="inverter_energy_today", + name="Energy today", + api_variable_key="_today_energy", + api_variable_metric="_today_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_energy_total", + name="Lifetime energy output", + api_variable_key="_total_energy", + api_variable_metric="_total_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=1, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + SunWEGSensorEntityDescription( + key="inverter_frequency", + name="AC frequency", + api_variable_key="_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_current_wattage", + name="Output power", + api_variable_key="_power", + api_variable_metric="_power_metric", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_temperature", + name="Temperature", + api_variable_key="_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:temperature-celsius", + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_power_factor", + name="Power Factor", + api_variable_key="_power_factor", + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/phase.py b/homeassistant/components/sunweg/sensor_types/phase.py new file mode 100644 index 00000000000..ca6b9374e0d --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/phase.py @@ -0,0 +1,26 @@ +"""SunWEG Sensor definitions for the Phase type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential + +from .sensor_entity_description import SunWEGSensorEntityDescription + +PHASE_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="voltage", + name="Voltage", + api_variable_key="_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="amperage", + name="Amperage", + api_variable_key="_amperage", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py new file mode 100644 index 00000000000..c3a00df6b6f --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py @@ -0,0 +1,23 @@ +"""Sensor Entity Description for the SunWEG integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription + + +@dataclass +class SunWEGRequiredKeysMixin: + """Mixin for required keys.""" + + api_variable_key: str + + +@dataclass +class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): + """Describes SunWEG sensor entity.""" + + api_variable_metric: str | None = None + previous_value_drop_threshold: float | None = None + never_resets: bool = False + icon: str | None = None diff --git a/homeassistant/components/sunweg/sensor_types/string.py b/homeassistant/components/sunweg/sensor_types/string.py new file mode 100644 index 00000000000..d3ee0a43c21 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/string.py @@ -0,0 +1,26 @@ +"""SunWEG Sensor definitions for the String type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential + +from .sensor_entity_description import SunWEGSensorEntityDescription + +STRING_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="voltage", + name="Voltage", + api_variable_key="_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="amperage", + name="Amperage", + api_variable_key="_amperage", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/total.py b/homeassistant/components/sunweg/sensor_types/total.py new file mode 100644 index 00000000000..da874be7a24 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/total.py @@ -0,0 +1,54 @@ +"""SunWEG Sensor definitions for Totals.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import UnitOfEnergy, UnitOfPower + +from .sensor_entity_description import SunWEGSensorEntityDescription + +TOTAL_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="total_money_total", + name="Money lifetime", + api_variable_key="_saving", + icon="mdi:cash", + native_unit_of_measurement="R$", + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="total_energy_today", + name="Energy Today", + api_variable_key="_today_energy", + api_variable_metric="_today_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SunWEGSensorEntityDescription( + key="total_output_power", + name="Output Power", + api_variable_key="_total_power", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + SunWEGSensorEntityDescription( + key="total_energy_output", + name="Lifetime energy output", + api_variable_key="_total_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + SunWEGSensorEntityDescription( + key="kwh_per_kwp", + name="kWh por kWp", + api_variable_key="_kwh_per_kwp", + ), + SunWEGSensorEntityDescription( + key="last_update", + name="Last Update", + api_variable_key="_last_update", + device_class=SensorDeviceClass.DATE, + ), +) diff --git a/homeassistant/components/sunweg/strings.json b/homeassistant/components/sunweg/strings.json new file mode 100644 index 00000000000..3a910e62940 --- /dev/null +++ b/homeassistant/components/sunweg/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "no_plants": "No plants have been found on this account" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plant" + }, + "title": "Select your plant" + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "title": "Enter your Sun WEG information" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1b620f9018b..164aa2acdd2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -472,6 +472,7 @@ FLOWS = { "stookwijzer", "subaru", "sun", + "sunweg", "surepetcare", "switchbee", "switchbot", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9fc28e59ee2..89c5ee6a80d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5545,6 +5545,12 @@ "config_flow": true, "iot_class": "calculated" }, + "sunweg": { + "name": "Sun WEG", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "supervisord": { "name": "Supervisord", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 054a3e300e4..5e3e6a1224a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2552,6 +2552,9 @@ subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 +# homeassistant.components.sunweg +sunweg==2.0.0 + # homeassistant.components.surepetcare surepy==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0dd713b162..956d7079981 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1913,6 +1913,9 @@ subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 +# homeassistant.components.sunweg +sunweg==2.0.0 + # homeassistant.components.surepetcare surepy==0.8.0 diff --git a/tests/components/sunweg/__init__.py b/tests/components/sunweg/__init__.py new file mode 100644 index 00000000000..1453483a3fd --- /dev/null +++ b/tests/components/sunweg/__init__.py @@ -0,0 +1 @@ +"""Tests for the sunweg component.""" diff --git a/tests/components/sunweg/common.py b/tests/components/sunweg/common.py new file mode 100644 index 00000000000..075af21f74b --- /dev/null +++ b/tests/components/sunweg/common.py @@ -0,0 +1,63 @@ +"""Common functions needed to setup tests for Sun WEG.""" +from datetime import datetime + +from sunweg.device import MPPT, Inverter, Phase, String +from sunweg.plant import Plant + +from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +FIXTURE_USER_INPUT = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", +} + +SUNWEG_PLANT_RESPONSE = Plant( + 123456, + "Plant #123", + 29.5, + 0.5, + 0, + 12.786912, + 24.0, + "kWh", + 332.2, + 0.012296, + datetime(2023, 2, 16, 14, 22, 37), +) + +SUNWEG_INVERTER_RESPONSE = Inverter( + 21255, + "INVERSOR01", + "J63T233018RE074", + 23.2, + 0.0, + 0.0, + "MWh", + 0, + "kWh", + 0.0, + 1, + 0, + "kW", +) + +SUNWEG_PHASE_RESPONSE = Phase("PhaseA", 120.0, 3.2, 0, 0) + +SUNWEG_MPPT_RESPONSE = MPPT("MPPT1") + +SUNWEG_STRING_RESPONSE = String("STR1", 450.3, 23.4, 0) + +SUNWEG_LOGIN_RESPONSE = True + +SUNWEG_MOCK_ENTRY = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_PLANT_ID: 0, + CONF_NAME: "Name", + }, +) diff --git a/tests/components/sunweg/test_config_flow.py b/tests/components/sunweg/test_config_flow.py new file mode 100644 index 00000000000..64d7816f077 --- /dev/null +++ b/tests/components/sunweg/test_config_flow.py @@ -0,0 +1,135 @@ +"""Tests for the Sun WEG server config flow.""" +from copy import deepcopy +from unittest.mock import patch + +from sunweg.api import APIHelper + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .common import FIXTURE_USER_INPUT, SUNWEG_LOGIN_RESPONSE, SUNWEG_PLANT_RESPONSE + +from tests.common import MockConfigEntry + + +async def test_show_authenticate_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_incorrect_login(hass: HomeAssistant) -> None: + """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch.object(APIHelper, "authenticate", return_value=False): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_no_plants_on_account(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object(APIHelper, "listPlants", return_value=[]): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == "abort" + assert result["reason"] == "no_plants" + + +async def test_multiple_plant_ids(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + plant_list = [deepcopy(SUNWEG_PLANT_RESPONSE), deepcopy(SUNWEG_PLANT_RESPONSE)] + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object(APIHelper, "listPlants", return_value=plant_list): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "plant" + + user_input = {CONF_PLANT_ID: 123456} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == 123456 + + +async def test_one_plant_on_account(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object( + APIHelper, + "listPlants", + return_value=[deepcopy(SUNWEG_PLANT_RESPONSE)], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == 123456 + + +async def test_existing_plant_configured(hass: HomeAssistant) -> None: + """Test entering an existing plant_id.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=123456) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object( + APIHelper, + "listPlants", + return_value=[deepcopy(SUNWEG_PLANT_RESPONSE)], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py new file mode 100644 index 00000000000..216a0254e66 --- /dev/null +++ b/tests/components/sunweg/test_init.py @@ -0,0 +1,146 @@ +"""Tests for the Sun WEG init.""" + +from copy import deepcopy +import json +from unittest.mock import MagicMock, patch + +from sunweg.api import APIHelper +from sunweg.device import MPPT, Inverter +from sunweg.plant import Plant + +from homeassistant.components.sunweg import SunWEGData +from homeassistant.components.sunweg.const import DOMAIN +from homeassistant.components.sunweg.sensor_types.sensor_entity_description import ( + SunWEGSensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + SUNWEG_INVERTER_RESPONSE, + SUNWEG_LOGIN_RESPONSE, + SUNWEG_MOCK_ENTRY, + SUNWEG_MPPT_RESPONSE, + SUNWEG_PHASE_RESPONSE, + SUNWEG_PLANT_RESPONSE, + SUNWEG_STRING_RESPONSE, +) + + +async def test_methods(hass: HomeAssistant) -> None: + """Test methods.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + mppt: MPPT = deepcopy(SUNWEG_MPPT_RESPONSE) + mppt.strings.append(SUNWEG_STRING_RESPONSE) + inverter: Inverter = deepcopy(SUNWEG_INVERTER_RESPONSE) + inverter.phases.append(SUNWEG_PHASE_RESPONSE) + inverter.mppts.append(mppt) + plant: Plant = deepcopy(SUNWEG_PLANT_RESPONSE) + plant.inverters.append(inverter) + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object(APIHelper, "listPlants", return_value=[plant]), patch.object( + APIHelper, "plant", return_value=plant + ), patch.object( + APIHelper, "inverter", return_value=inverter + ), patch.object( + APIHelper, "complete_inverter" + ): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(mock_entry.entry_id) + + +async def test_setup_wrongpass(hass: HomeAssistant) -> None: + """Test setup with wrong pass.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + with patch.object(APIHelper, "authenticate", return_value=False): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + + +async def test_sunwegdata_update_exception() -> None: + """Test SunWEGData exception on update.""" + api = MagicMock() + api.plant = MagicMock(side_effect=json.decoder.JSONDecodeError("Message", "Doc", 1)) + data = SunWEGData(api, 0) + data.update() + assert data.data is None + + +async def test_sunwegdata_update_success() -> None: + """Test SunWEGData success on update.""" + inverter: Inverter = deepcopy(SUNWEG_INVERTER_RESPONSE) + plant: Plant = deepcopy(SUNWEG_PLANT_RESPONSE) + plant.inverters.append(inverter) + api = MagicMock() + api.plant = MagicMock(return_value=plant) + api.complete_inverter = MagicMock() + data = SunWEGData(api, 0) + data.update() + assert data.data.id == plant.id + assert data.data.name == plant.name + assert data.data.kwh_per_kwp == plant.kwh_per_kwp + assert data.data.last_update == plant.last_update + assert data.data.performance_rate == plant.performance_rate + assert data.data.saving == plant.saving + assert len(data.data.inverters) == 1 + + +async def test_sunwegdata_get_api_value_none() -> None: + """Test SunWEGData none return on get_api_value.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.data = deepcopy(SUNWEG_PLANT_RESPONSE) + assert data.get_api_value("variable", "inverter", 0, "deep_name") is None + data.data.inverters.append(deepcopy(SUNWEG_INVERTER_RESPONSE)) + assert data.get_api_value("variable", "invalid type", 21255, "deep_name") is None + + +async def test_sunwegdata_get_data_drop_threshold() -> None: + """Test SunWEGData get_data with drop threshold.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.get_api_value = MagicMock() + entity_description = SunWEGSensorEntityDescription( + api_variable_key="variable", key="key" + ) + entity_description.previous_value_drop_threshold = 0.1 + data.get_api_value.return_value = 3.0 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 2.91 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 2.8 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 2.8 + ) + + +async def test_sunwegdata_get_data_never_reset() -> None: + """Test SunWEGData get_data with never reset.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.get_api_value = MagicMock() + entity_description = SunWEGSensorEntityDescription( + api_variable_key="variable", key="key" + ) + entity_description.never_resets = True + data.get_api_value.return_value = 3.0 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 0 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 2.8 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 2.8 + ) From 4d708f1931f1599cdb8860b8d353c1fc2ac955ea Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 9 Dec 2023 09:47:28 +0100 Subject: [PATCH 614/643] Migrate template tests to use freezegun (#105341) --- tests/components/template/test_button.py | 22 ++++---- tests/components/template/test_trigger.py | 64 +++++++++++------------ 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index bfdb9352767..ece568eee49 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -1,6 +1,7 @@ """The tests for the Template button platform.""" import datetime as dt -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant import setup from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS @@ -59,7 +60,9 @@ async def test_missing_required_keys(hass: HomeAssistant) -> None: assert hass.states.async_all("button") == [] -async def test_all_optional_config(hass: HomeAssistant, calls) -> None: +async def test_all_optional_config( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test: including all optional templates is ok.""" with assert_setup_component(1, "template"): assert await setup.async_setup_component( @@ -98,14 +101,13 @@ async def test_all_optional_config(hass: HomeAssistant, calls) -> None: ) now = dt.datetime.now(dt.UTC) - - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, - blocking=True, - ) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, + blocking=True, + ) assert len(calls) == 1 assert calls[0].data["caller"] == _TEST_OPTIONS_BUTTON diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index 84fdadfec0d..af010c57e2e 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -1,8 +1,8 @@ """The tests for the Template automation.""" from datetime import timedelta from unittest import mock -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import homeassistant.components.automation as automation @@ -803,56 +803,56 @@ async def test_invalid_for_template_1(hass: HomeAssistant, start_ha, calls) -> N assert mock_logger.error.called -async def test_if_fires_on_time_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_time_change( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing on time changes.""" start_time = dt_util.utcnow() + timedelta(hours=24) time_that_will_not_match_right_away = start_time.replace(minute=1, second=0) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ utcnow().minute % 2 == 0 }}", - }, - "action": {"service": "test.automation"}, - } - }, - ) - await hass.async_block_till_done() - assert len(calls) == 0 + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ utcnow().minute % 2 == 0 }}", + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 # Trigger once (match template) first_time = start_time.replace(minute=2, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=first_time): - async_fire_time_changed(hass, first_time) - await hass.async_block_till_done() + freezer.move_to(first_time) + async_fire_time_changed(hass, first_time) + await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (match template) second_time = start_time.replace(minute=4, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=second_time): - async_fire_time_changed(hass, second_time) - await hass.async_block_till_done() + freezer.move_to(second_time) + async_fire_time_changed(hass, second_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (do not match template) third_time = start_time.replace(minute=5, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=third_time): - async_fire_time_changed(hass, third_time) - await hass.async_block_till_done() + freezer.move_to(third_time) + async_fire_time_changed(hass, third_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (match template) forth_time = start_time.replace(minute=8, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=forth_time): - async_fire_time_changed(hass, forth_time) - await hass.async_block_till_done() + freezer.move_to(forth_time) + async_fire_time_changed(hass, forth_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 2 From cc85e89cf2a022a23c12a7720802dfb699c3825b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Dec 2023 23:19:08 -1000 Subject: [PATCH 615/643] Make network fixture scoped to session to speed up tests (#105353) --- tests/conftest.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4d0e2565164..777b2073847 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,6 @@ from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant, legacy_api_password from homeassistant.components.device_tracker.legacy import Device -from homeassistant.components.network.models import Adapter, IPv4ConfiguredAddress from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, @@ -1096,21 +1095,18 @@ async def mqtt_mock_entry( yield _setup_mqtt_entry -@pytest.fixture(autouse=True) +@pytest.fixture(autouse=True, scope="session") def mock_network() -> Generator[None, None, None]: """Mock network.""" - mock_adapter = Adapter( - name="eth0", - index=0, - enabled=True, - auto=True, - default=True, - ipv4=[IPv4ConfiguredAddress(address="10.10.10.10", network_prefix=24)], - ipv6=[], - ) with patch( - "homeassistant.components.network.network.async_load_adapters", - return_value=[mock_adapter], + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[ + Mock( + nice_name="eth0", + ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], + index=0, + ) + ], ): yield From 2d02cdcd0daf8afe4afa86c1004431ebf018c524 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 9 Dec 2023 10:19:42 +0100 Subject: [PATCH 616/643] Migrate gree tests to use freezegun (#105340) --- tests/components/gree/test_bridge.py | 8 ++-- tests/components/gree/test_climate.py | 57 ++++++++++++++------------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index b13544fd3f7..f40ab6525d4 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -1,7 +1,7 @@ """Tests for gree component.""" from datetime import timedelta -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN @@ -24,7 +24,7 @@ def mock_now(): async def test_discovery_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices don't change after multiple discoveries.""" mock_device_1 = build_device_mock( @@ -58,8 +58,8 @@ async def test_discovery_after_setup( device.side_effect = [mock_device_1, mock_device_2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 82ad75b5d28..f5af1f403c3 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory from greeclimate.device import HorizontalSwing, VerticalSwing from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError import pytest @@ -115,7 +116,7 @@ async def test_discovery_setup_connection_error( async def test_discovery_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices don't change after multiple discoveries.""" MockDevice1 = build_device_mock( @@ -142,8 +143,8 @@ async def test_discovery_after_setup( device.side_effect = [MockDevice1, MockDevice2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 @@ -151,7 +152,7 @@ async def test_discovery_after_setup( async def test_discovery_add_device_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices can be added after initial setup.""" MockDevice1 = build_device_mock( @@ -178,8 +179,8 @@ async def test_discovery_add_device_after_setup( device.side_effect = [MockDevice2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 @@ -187,7 +188,7 @@ async def test_discovery_add_device_after_setup( async def test_discovery_device_bind_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices can be added after a late device bind.""" MockDevice1 = build_device_mock( @@ -212,15 +213,17 @@ async def test_discovery_device_bind_after_setup( MockDevice1.update_state.side_effect = None next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state != STATE_UNAVAILABLE -async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) -> None: +async def test_update_connection_failure( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, device, mock_now +) -> None: """Testing update hvac connection failure exception.""" device().update_state.side_effect = [ DEFAULT_MOCK, @@ -231,8 +234,8 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) await async_setup_gree(hass) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() # First update to make the device available @@ -241,13 +244,13 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) assert state.state != STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() next_update = mock_now + timedelta(minutes=15) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() # Then two more update failures to make the device unavailable @@ -257,7 +260,7 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) async def test_update_connection_failure_recovery( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Testing update hvac connection failure recovery.""" device().update_state.side_effect = [ @@ -270,8 +273,8 @@ async def test_update_connection_failure_recovery( # First update becomes unavailable next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -280,8 +283,8 @@ async def test_update_connection_failure_recovery( # Second update restores the connection next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -290,7 +293,7 @@ async def test_update_connection_failure_recovery( async def test_update_unhandled_exception( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Testing update hvac connection unhandled response exception.""" device().update_state.side_effect = [DEFAULT_MOCK, Exception] @@ -302,8 +305,8 @@ async def test_update_unhandled_exception( assert state.state != STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -312,15 +315,15 @@ async def test_update_unhandled_exception( async def test_send_command_device_timeout( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test for sending power on command to the device with a device timeout.""" await async_setup_gree(hass) # First update to make the device available next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) From d54c36307a839578e086775d1eed1c6be445d7c7 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 9 Dec 2023 20:53:47 +1000 Subject: [PATCH 617/643] Fix the ruff-format error (#105376) --- tests/components/sunweg/test_init.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py index 216a0254e66..fd9ab5ce895 100644 --- a/tests/components/sunweg/test_init.py +++ b/tests/components/sunweg/test_init.py @@ -43,9 +43,7 @@ async def test_methods(hass: HomeAssistant) -> None: APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE ), patch.object(APIHelper, "listPlants", return_value=[plant]), patch.object( APIHelper, "plant", return_value=plant - ), patch.object( - APIHelper, "inverter", return_value=inverter - ), patch.object( + ), patch.object(APIHelper, "inverter", return_value=inverter), patch.object( APIHelper, "complete_inverter" ): assert await async_setup_component(hass, DOMAIN, mock_entry.data) From 1eaf208450e43551485380d54828d51663f99b7b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Dec 2023 12:29:23 +0100 Subject: [PATCH 618/643] Update freezegun to 1.3.1 (#105377) --- pyproject.toml | 3 --- requirements_test.txt | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d7764864168..9e9e8de4916 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -455,9 +455,6 @@ filterwarnings = [ "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:apprise.AppriseLocal", # https://github.com/kiorky/croniter/issues/49 - v1.4.1 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:croniter.croniter", - # https://github.com/spulec/freezegun/issues/508 - v1.2.2 - # https://github.com/spulec/freezegun/pull/511 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:freezegun.api", # https://github.com/influxdata/influxdb-client-python/issues/603 - v1.37.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 diff --git a/requirements_test.txt b/requirements_test.txt index d880fecaca5..f8918dc73f4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,7 +9,7 @@ -r requirements_test_pre_commit.txt astroid==3.0.1 coverage==7.3.2 -freezegun==1.2.2 +freezegun==1.3.1 mock-open==1.4.0 mypy==1.7.1 pre-commit==3.5.0 From b5785003a3737b387e5fd2906f42386dd4ff18d0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 9 Dec 2023 12:55:40 +0100 Subject: [PATCH 619/643] Migrate unifi tests to use freezegun (#105343) --- tests/components/unifi/test_device_tracker.py | 22 +++++++++---------- tests/components/unifi/test_sensor.py | 16 ++++++++------ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index abe12a1e243..5a12b99d10b 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,9 +1,8 @@ """The tests for the UniFi Network device tracker platform.""" from datetime import timedelta -from unittest.mock import patch from aiounifi.models.message import MessageKey -from freezegun.api import FrozenDateTimeFactory +from freezegun.api import FrozenDateTimeFactory, freeze_time from homeassistant import config_entries from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -72,7 +71,7 @@ async def test_tracked_wireless_clients( # Change time to mark client as away new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -293,6 +292,7 @@ async def test_tracked_wireless_clients_event_source( async def test_tracked_devices( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, mock_unifi_websocket, mock_device_registry, ) -> None: @@ -351,9 +351,9 @@ async def test_tracked_devices( # Change of time can mark device not_home outside of expected reporting interval new_time = dt_util.utcnow() + timedelta(seconds=90) - with patch("homeassistant.util.dt.utcnow", return_value=new_time): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() assert hass.states.get("device_tracker.device_1").state == STATE_NOT_HOME assert hass.states.get("device_tracker.device_2").state == STATE_HOME @@ -712,7 +712,7 @@ async def test_option_ssid_filter( await hass.async_block_till_done() new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -740,7 +740,7 @@ async def test_option_ssid_filter( # Time pass to mark client as away new_time += controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -759,7 +759,7 @@ async def test_option_ssid_filter( await hass.async_block_till_done() new_time += controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -808,7 +808,7 @@ async def test_wireless_client_go_wired_issue( # Pass time new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -877,7 +877,7 @@ async def test_option_ignore_wired_bug( # pass time new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index f4366b98fc3..854d136f3dd 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from unittest.mock import patch from aiounifi.models.message import MessageKey +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -429,6 +430,7 @@ async def test_bandwidth_sensors( async def test_uptime_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, mock_unifi_websocket, entity_registry_enabled_by_default: None, initial_uptime, @@ -450,13 +452,13 @@ async def test_uptime_sensors( } now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.now", return_value=now): - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options=options, - clients_response=[uptime_client], - ) + freezer.move_to(now) + config_entry = await setup_unifi_integration( + hass, + aioclient_mock, + options=options, + clients_response=[uptime_client], + ) assert len(hass.states.async_all()) == 2 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 From 25586f9efd26e24aee0fbd0ad8ae18e8fd9c579f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 9 Dec 2023 13:06:18 +0100 Subject: [PATCH 620/643] Add data descriptions to Reolink (#105298) --- homeassistant/components/reolink/strings.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5a27f0e38cb..04b46323e11 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -12,7 +12,11 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'." + "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.", + "port": "The port to connect to the Reolink device. For HTTP normally: '80', for HTTPS normally '443'.", + "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", + "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", + "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } }, "reauth_confirm": { @@ -38,6 +42,9 @@ "init": { "data": { "protocol": "Protocol" + }, + "data_description": { + "protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (h265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera." } } } From f37f40c33818d178d3b734be00be9bd1d4d99b25 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 9 Dec 2023 13:49:32 +0100 Subject: [PATCH 621/643] Fix preset modes error in Smartthings (#105375) --- homeassistant/components/smartthings/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index b97ca06a471..f07c293939a 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -528,10 +528,10 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" - supported_modes = self._device.status.attributes[ + supported_modes: list | None = self._device.status.attributes[ "supportedAcOptionalMode" ].value - if WINDFREE in supported_modes: + if supported_modes and WINDFREE in supported_modes: return [WINDFREE] return None From 35b733fa2c851983d701c0deebea206c03c70f3c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 9 Dec 2023 16:12:05 +0100 Subject: [PATCH 622/643] Bump `aioshelly` to version 7.0.0 (#105384) * Remove get_rpc_device_sleep_period() function * Bump aioshelly version to 7.0.0 * Remove firmware compatibility check from BLE scanner * Remove firmware compatibility check from light transition * Update default fw ver * Use LightEntityFeature in tests --- homeassistant/components/shelly/__init__.py | 5 +- .../components/shelly/config_flow.py | 18 +----- homeassistant/components/shelly/const.py | 6 -- .../components/shelly/coordinator.py | 10 ---- homeassistant/components/shelly/light.py | 9 +-- homeassistant/components/shelly/manifest.json | 2 +- homeassistant/components/shelly/strings.json | 3 - homeassistant/components/shelly/utils.py | 9 --- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../shelly/bluetooth/test_scanner.py | 13 ----- tests/components/shelly/conftest.py | 30 ++-------- tests/components/shelly/test_config_flow.py | 56 ------------------- tests/components/shelly/test_light.py | 7 ++- 14 files changed, 18 insertions(+), 154 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index b29fdcc6d19..553d32f8e48 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -49,7 +49,6 @@ from .utils import ( get_block_device_sleep_period, get_coap_context, get_device_entry_gen, - get_rpc_device_sleep_period, get_rpc_device_wakeup_period, get_ws_context, ) @@ -266,9 +265,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo if sleep_period is None: data = {**entry.data} - data[CONF_SLEEP_PERIOD] = get_rpc_device_sleep_period( - device.config - ) or get_rpc_device_wakeup_period(device.status) + data[CONF_SLEEP_PERIOD] = get_rpc_device_wakeup_period(device.status) hass.config_entries.async_update_entry(entry, data=data) hass.async_create_task(_async_rpc_device_setup()) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 6cde265bc25..98233d27b22 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -12,7 +12,6 @@ from aioshelly.exceptions import ( InvalidAuthError, ) from aioshelly.rpc_device import RpcDevice -from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -24,7 +23,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .const import ( - BLE_MIN_VERSION, CONF_BLE_SCANNER_MODE, CONF_SLEEP_PERIOD, DOMAIN, @@ -32,14 +30,13 @@ from .const import ( MODEL_WALL_DISPLAY, BLEScannerMode, ) -from .coordinator import async_reconnect_soon, get_entry_data +from .coordinator import async_reconnect_soon from .utils import ( get_block_device_sleep_period, get_coap_context, get_info_auth, get_info_gen, get_model_name, - get_rpc_device_sleep_period, get_rpc_device_wakeup_period, get_ws_context, mac_address_from_name, @@ -78,9 +75,7 @@ async def validate_input( ) await rpc_device.shutdown() - sleep_period = get_rpc_device_sleep_period( - rpc_device.config - ) or get_rpc_device_wakeup_period(rpc_device.status) + sleep_period = get_rpc_device_wakeup_period(rpc_device.status) return { "title": rpc_device.name, @@ -383,15 +378,6 @@ class OptionsFlowHandler(OptionsFlow): ) -> FlowResult: """Handle options flow.""" if user_input is not None: - entry_data = get_entry_data(self.hass)[self.config_entry.entry_id] - if user_input[CONF_BLE_SCANNER_MODE] != BLEScannerMode.DISABLED and ( - not entry_data.rpc - or AwesomeVersion(entry_data.rpc.device.version) < BLE_MIN_VERSION - ): - return self.async_abort( - reason="ble_unsupported", - description_placeholders={"ble_min_version": BLE_MIN_VERSION}, - ) return self.async_create_entry(title="", data=user_input) return self.async_show_form( diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index a90aba8db62..ca1c450c9fa 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -22,7 +22,6 @@ from aioshelly.const import ( MODEL_VINTAGE_V2, MODEL_WALL_DISPLAY, ) -from awesomeversion import AwesomeVersion DOMAIN: Final = "shelly" @@ -33,9 +32,6 @@ CONF_COAP_PORT: Final = "coap_port" DEFAULT_COAP_PORT: Final = 5683 FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})") -# Firmware 1.11.0 release date, this firmware supports light transition -LIGHT_TRANSITION_MIN_FIRMWARE_DATE: Final = 20210226 - # max light transition time in milliseconds MAX_TRANSITION_TIME: Final = 5000 @@ -187,8 +183,6 @@ ENTRY_RELOAD_COOLDOWN = 60 SHELLY_GAS_MODELS = [MODEL_GAS] -BLE_MIN_VERSION = AwesomeVersion("0.12.0-beta2") - CONF_BLE_SCANNER_MODE = "ble_scanner_mode" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d1f9d6943bf..a7659ecc392 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -13,7 +13,6 @@ from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.const import MODEL_VALVE from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from aioshelly.rpc_device import RpcDevice, RpcUpdateType -from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -33,7 +32,6 @@ from .const import ( ATTR_DEVICE, ATTR_GENERATION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, - BLE_MIN_VERSION, CONF_BLE_SCANNER_MODE, CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, @@ -587,14 +585,6 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) return - if AwesomeVersion(self.device.version) < BLE_MIN_VERSION: - LOGGER.error( - "BLE not supported on device %s with firmware %s; upgrade to %s", - self.name, - self.device.version, - BLE_MIN_VERSION, - ) - return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting # the scanner since it will be disconnected anyway diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 829a60b3a9e..2dfc5b497b1 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -24,11 +24,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DUAL_MODE_LIGHT_MODELS, - FIRMWARE_PATTERN, KELVIN_MAX_VALUE, KELVIN_MIN_VALUE_COLOR, KELVIN_MIN_VALUE_WHITE, - LIGHT_TRANSITION_MIN_FIRMWARE_DATE, LOGGER, MAX_TRANSITION_TIME, MODELS_SUPPORTING_LIGHT_TRANSITION, @@ -155,12 +153,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): self._attr_supported_features |= LightEntityFeature.EFFECT if coordinator.model in MODELS_SUPPORTING_LIGHT_TRANSITION: - match = FIRMWARE_PATTERN.search(coordinator.device.settings.get("fw", "")) - if ( - match is not None - and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE - ): - self._attr_supported_features |= LightEntityFeature.TRANSITION + self._attr_supported_features |= LightEntityFeature.TRANSITION @property def is_on(self) -> bool: diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index b8185712d31..b56ce07bc30 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==6.1.0"], + "requirements": ["aioshelly==7.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 9230ae605e0..330dd246c47 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -71,9 +71,6 @@ "ble_scanner_mode": "Bluetooth scanner mode" } } - }, - "abort": { - "ble_unsupported": "Bluetooth support requires firmware version {ble_min_version} or newer." } }, "selector": { diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 6b5c59f28db..b53e3153a09 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -267,15 +267,6 @@ def get_block_device_sleep_period(settings: dict[str, Any]) -> int: return sleep_period * 60 # minutes to seconds -def get_rpc_device_sleep_period(config: dict[str, Any]) -> int: - """Return the device sleep period in seconds or 0 for non sleeping devices. - - sys.sleep.wakeup_period value is deprecated and not available in Shelly - firmware 1.0.0 or later. - """ - return cast(int, config["sys"].get("sleep", {}).get("wakeup_period", 0)) - - def get_rpc_device_wakeup_period(status: dict[str, Any]) -> int: """Return the device wakeup period in seconds or 0 for non sleeping devices.""" return cast(int, status["sys"].get("wakeup_period", 0)) diff --git a/requirements_all.txt b/requirements_all.txt index 5e3e6a1224a..1dccc1a5551 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,7 +356,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.1.0 +aioshelly==7.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 956d7079981..57ec5a22df9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -329,7 +329,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.1.0 +aioshelly==7.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/bluetooth/test_scanner.py b/tests/components/shelly/bluetooth/test_scanner.py index bd44782f928..9fe5f77f00c 100644 --- a/tests/components/shelly/bluetooth/test_scanner.py +++ b/tests/components/shelly/bluetooth/test_scanner.py @@ -108,19 +108,6 @@ async def test_scanner_ignores_wrong_version_and_logs( assert "Unsupported BLE scan result version: 0" in caplog.text -async def test_scanner_minimum_firmware_log_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture -) -> None: - """Test scanner log error if device firmware incompatible.""" - monkeypatch.setattr(mock_rpc_device, "version", "0.11.0") - await init_integration( - hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} - ) - assert mock_rpc_device.initialized is True - - assert "BLE not supported on device" in caplog.text - - async def test_scanner_warns_on_corrupt_event( hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 6eb74e26dcb..8a863a852f5 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -157,14 +157,13 @@ MOCK_CONFIG = { "sys": { "ui_data": {}, "device": {"name": "Test name"}, - "wakeup_period": 0, }, } MOCK_SHELLY_COAP = { "mac": MOCK_MAC, "auth": False, - "fw": "20201124-092854/v1.9.0@57ac4ad8", + "fw": "20210715-092854/v1.11.0@57ac4ad8", "num_outputs": 2, } @@ -174,8 +173,8 @@ MOCK_SHELLY_RPC = { "mac": MOCK_MAC, "model": MODEL_PLUS_2PM, "gen": 2, - "fw_id": "20220830-130540/0.11.0-gfa1bc37", - "ver": "0.11.0", + "fw_id": "20230803-130540/1.0.0-gfa1bc37", + "ver": "1.0.0", "app": "Plus2PM", "auth_en": False, "auth_domain": None, @@ -290,7 +289,7 @@ async def mock_block_device(): blocks=MOCK_BLOCKS, settings=MOCK_SETTINGS, shelly=MOCK_SHELLY_COAP, - version="0.10.0", + version="1.11.0", status=MOCK_STATUS_COAP, firmware_version="some fw string", initialized=True, @@ -314,7 +313,7 @@ def _mock_rpc_device(version: str | None = None): config=MOCK_CONFIG, event={}, shelly=MOCK_SHELLY_RPC, - version=version or "0.12.0", + version=version or "1.0.0", hostname="test-host", status=MOCK_STATUS_RPC, firmware_version="some fw string", @@ -324,23 +323,6 @@ def _mock_rpc_device(version: str | None = None): return device -@pytest.fixture -async def mock_pre_ble_rpc_device(): - """Mock rpc (Gen2, Websocket) device pre BLE.""" - with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: - - def update(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.STATUS - ) - - device = _mock_rpc_device("0.11.0") - rpc_device_mock.return_value = device - rpc_device_mock.return_value.mock_update = Mock(side_effect=update) - - yield rpc_device_mock.return_value - - @pytest.fixture async def mock_rpc_device(): """Mock rpc (Gen2, Websocket) device with BLE support.""" @@ -363,7 +345,7 @@ async def mock_rpc_device(): {}, RpcUpdateType.DISCONNECTED ) - device = _mock_rpc_device("0.12.0") + device = _mock_rpc_device() rpc_device_mock.return_value = device rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=disconnected) rpc_device_mock.return_value.mock_update = Mock(side_effect=update) diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 9482080a1a3..c7ac472ada4 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -967,62 +967,6 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device) -> None: await hass.config_entries.async_unload(entry.entry_id) -async def test_options_flow_pre_ble_device( - hass: HomeAssistant, mock_pre_ble_rpc_device -) -> None: - """Test setting ble options for gen2 devices with pre ble firmware.""" - entry = await init_integration(hass, 2) - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "ble_unsupported" - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.PASSIVE, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "ble_unsupported" - - await hass.config_entries.async_unload(entry.entry_id) - - async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( hass: HomeAssistant, mock_rpc_device, monkeypatch ) -> None: diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index e3aea966230..77b65ad3bb5 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -134,7 +134,10 @@ async def test_block_device_rgb_bulb( ColorMode.COLOR_TEMP, ColorMode.RGB, ] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert ( + attributes[ATTR_SUPPORTED_FEATURES] + == LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION + ) assert len(attributes[ATTR_EFFECT_LIST]) == 4 assert attributes[ATTR_EFFECT] == "Off" @@ -232,7 +235,7 @@ async def test_block_device_white_bulb( assert state.state == STATE_ON assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() From c96a58893495ad493a667d0b4782b7b95118f0fa Mon Sep 17 00:00:00 2001 From: mkmer Date: Sat, 9 Dec 2023 13:18:59 -0500 Subject: [PATCH 623/643] Fix service missing key in Blink (#105387) * fix update service refactor service yaml * Remove leftover target --- homeassistant/components/blink/services.py | 7 +++- homeassistant/components/blink/services.yaml | 44 +++++++++++++------- homeassistant/components/blink/strings.json | 28 ++++++++++++- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 12ac0d3b859..dae2f0ad951 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -25,6 +25,11 @@ from .const import ( ) from .coordinator import BlinkUpdateCoordinator +SERVICE_UPDATE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + } +) SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), @@ -152,7 +157,7 @@ def setup_services(hass: HomeAssistant) -> None: # Register all the above services service_mapping = [ - (blink_refresh, SERVICE_REFRESH, None), + (blink_refresh, SERVICE_REFRESH, SERVICE_UPDATE_SCHEMA), ( async_handle_save_video_service, SERVICE_SAVE_VIDEO, diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index f6420e7f004..aaecde64353 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -1,18 +1,28 @@ # Describes the format for available Blink services blink_update: + fields: + device_id: + required: true + selector: + device: + integration: blink + trigger_camera: - target: - entity: - integration: blink - domain: camera + fields: + device_id: + required: true + selector: + device: + integration: blink save_video: - target: - entity: - integration: blink - domain: camera fields: + device_id: + required: true + selector: + device: + integration: blink name: required: true example: "Living Room" @@ -25,11 +35,12 @@ save_video: text: save_recent_clips: - target: - entity: - integration: blink - domain: camera fields: + device_id: + required: true + selector: + device: + integration: blink name: required: true example: "Living Room" @@ -42,11 +53,12 @@ save_recent_clips: text: send_pin: - target: - entity: - integration: blink - domain: camera fields: + device_id: + required: true + selector: + device: + integration: blink pin: example: "abc123" selector: diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index f47f72acb9c..fc0450dc8ea 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -57,11 +57,23 @@ "services": { "blink_update": { "name": "Update", - "description": "Forces a refresh." + "description": "Forces a refresh.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "The Blink device id." + } + } }, "trigger_camera": { "name": "Trigger camera", - "description": "Requests camera to take new image." + "description": "Requests camera to take new image.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "The Blink device id." + } + } }, "save_video": { "name": "Save video", @@ -74,6 +86,10 @@ "filename": { "name": "File name", "description": "Filename to writable path (directory may need to be included in allowlist_external_dirs in config)." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } }, @@ -88,6 +104,10 @@ "file_path": { "name": "Output directory", "description": "Directory name of writable path (directory may need to be included in allowlist_external_dirs in config)." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } }, @@ -98,6 +118,10 @@ "pin": { "name": "Pin", "description": "PIN received from blink. Leave empty if you only received a verification email." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } } From a0bf170fb45a5dbb5c0336f8a11c5771802aaa69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Dec 2023 08:41:37 -1000 Subject: [PATCH 624/643] Avoid ffmpeg subprocess for many component tests (#105354) --- tests/components/conftest.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 1ebcc864b4b..adf79a2ef96 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -91,3 +91,12 @@ def tts_mutagen_mock_fixture(): from tests.components.tts.common import tts_mutagen_mock_fixture_helper yield from tts_mutagen_mock_fixture_helper() + + +@pytest.fixture(scope="session", autouse=True) +def prevent_ffmpeg_subprocess() -> Generator[None, None, None]: + """Prevent ffmpeg from creating a subprocess.""" + with patch( + "homeassistant.components.ffmpeg.FFVersion.get_version", return_value="6.0" + ): + yield From a090bcb8a55f434b2ab3ce3537f82d03a6925ca4 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 9 Dec 2023 21:35:52 +0100 Subject: [PATCH 625/643] Migrate time_date tests to use freezegun (#105409) --- tests/components/time_date/test_sensor.py | 52 +++++++++++------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index 96c7edf422b..f9ef8a7cfe9 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -1,29 +1,30 @@ """The tests for time_date sensor platform.""" -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory import homeassistant.components.time_date.sensor as time_date from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -async def test_intervals(hass: HomeAssistant) -> None: +async def test_intervals(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test timing intervals of sensors.""" device = time_date.TimeDateSensor(hass, "time") now = dt_util.utc_from_timestamp(45.5) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.utc_from_timestamp(60) device = time_date.TimeDateSensor(hass, "beat") now = dt_util.parse_datetime("2020-11-13 00:00:29+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.parse_datetime("2020-11-13 00:01:26.4+01:00") device = time_date.TimeDateSensor(hass, "date_time") now = dt_util.utc_from_timestamp(1495068899) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.utc_from_timestamp(1495068900) now = dt_util.utcnow() @@ -102,14 +103,16 @@ async def test_states_non_default_timezone(hass: HomeAssistant) -> None: assert device.state == "2017-05-17T20:54:00" -async def test_timezone_intervals(hass: HomeAssistant) -> None: +async def test_timezone_intervals( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test date sensor behavior in a timezone besides UTC.""" hass.config.set_time_zone("America/New_York") device = time_date.TimeDateSensor(hass, "date") now = dt_util.utc_from_timestamp(50000) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() # start of local day in EST was 18000.0 # so the second day was 18000 + 86400 assert next_time.timestamp() == 104400 @@ -117,43 +120,40 @@ async def test_timezone_intervals(hass: HomeAssistant) -> None: hass.config.set_time_zone("America/Edmonton") now = dt_util.parse_datetime("2017-11-13 19:47:19-07:00") device = time_date.TimeDateSensor(hass, "date") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2017-11-14 00:00:00-07:00") # Entering DST hass.config.set_time_zone("Europe/Prague") now = dt_util.parse_datetime("2020-03-29 00:00+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") now = dt_util.parse_datetime("2020-03-29 03:00+02:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") # Leaving DST now = dt_util.parse_datetime("2020-10-25 00:00+02:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") now = dt_util.parse_datetime("2020-10-25 23:59+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") -@patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.parse_datetime("2017-11-14 02:47:19-00:00"), -) async def test_timezone_intervals_empty_parameter( - utcnow_mock, hass: HomeAssistant + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test get_interval() without parameters.""" + freezer.move_to(dt_util.parse_datetime("2017-11-14 02:47:19-00:00")) hass.config.set_time_zone("America/Edmonton") device = time_date.TimeDateSensor(hass, "date") next_time = device.get_next_interval() From 885410bcfcf5d96e0a3c0e648bbd9a0d1644873f Mon Sep 17 00:00:00 2001 From: vexofp Date: Sat, 9 Dec 2023 16:30:12 -0500 Subject: [PATCH 626/643] Prevent duplicate default SSLContext instances (#105348) Co-authored-by: J. Nick Koston --- homeassistant/util/ssl.py | 31 +++++++++++++++++++------------ tests/util/test_ssl.py | 9 +++++++++ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 2b503716063..6bfbec88a33 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -61,16 +61,11 @@ SSL_CIPHER_LISTS = { @cache -def create_no_verify_ssl_context( - ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, -) -> ssl.SSLContext: - """Return an SSL context that does not verify the server certificate. +def _create_no_verify_ssl_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: + # This is a copy of aiohttp's create_default_context() function, with the + # ssl verify turned off. + # https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 - This is a copy of aiohttp's create_default_context() function, with the - ssl verify turned off. - - https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 - """ sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) sslcontext.check_hostname = False sslcontext.verify_mode = ssl.CERT_NONE @@ -84,12 +79,16 @@ def create_no_verify_ssl_context( return sslcontext -@cache -def client_context( +def create_no_verify_ssl_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: - """Return an SSL context for making requests.""" + """Return an SSL context that does not verify the server certificate.""" + return _create_no_verify_ssl_context(ssl_cipher_list=ssl_cipher_list) + + +@cache +def _client_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: # Reuse environment variable definition from requests, since it's already a # requirement. If the environment variable has no value, fall back to using # certs from certifi package. @@ -104,6 +103,14 @@ def client_context( return sslcontext +def client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return an SSL context for making requests.""" + + return _client_context(ssl_cipher_list=ssl_cipher_list) + + # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = client_context() _DEFAULT_NO_VERIFY_SSL_CONTEXT = create_no_verify_ssl_context() diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py index 4d43859cc44..4a88e061cbc 100644 --- a/tests/util/test_ssl.py +++ b/tests/util/test_ssl.py @@ -51,3 +51,12 @@ def test_no_verify_ssl_context(mock_sslcontext) -> None: mock_sslcontext.set_ciphers.assert_called_with( SSL_CIPHER_LISTS[SSLCipherList.INTERMEDIATE] ) + + +def test_ssl_context_caching() -> None: + """Test that SSLContext instances are cached correctly.""" + + assert client_context() is client_context(SSLCipherList.PYTHON_DEFAULT) + assert create_no_verify_ssl_context() is create_no_verify_ssl_context( + SSLCipherList.PYTHON_DEFAULT + ) From 4e1677e3f0c96472fdbfe56a2596868aac08a8ad Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 9 Dec 2023 17:33:31 -0500 Subject: [PATCH 627/643] Remove zwave_js device on device reset (#104291) * Reload zwave_js config entry on device reset * remove device * Just remove the device and don't reload * revert change to notification message * Assert device is no longer there --- homeassistant/components/zwave_js/__init__.py | 9 ++++-- tests/components/zwave_js/test_init.py | 29 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index a8b3d300e3b..ccadc452bc7 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -449,7 +449,10 @@ class ControllerEvents: "remove_entity" ), ) - elif reason == RemoveNodeReason.RESET: + # We don't want to remove the device so we can keep the user customizations + return + + if reason == RemoveNodeReason.RESET: device_name = device.name_by_user or device.name or f"Node {node.node_id}" identifier = get_network_identifier_for_notification( self.hass, self.config_entry, self.driver_events.driver.controller @@ -471,8 +474,8 @@ class ControllerEvents: "Device Was Factory Reset!", f"{DOMAIN}.node_reset_and_removed.{dev_id[1]}", ) - else: - self.remove_device(device) + + self.remove_device(device) @callback def async_on_identify(self, event: dict) -> None: diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index bf015a70676..75a7397cc4e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1650,6 +1650,7 @@ async def test_factory_reset_node( hass: HomeAssistant, client, multisensor_6, multisensor_6_state, integration ) -> None: """Test when a node is removed because it was reset.""" + dev_reg = dr.async_get(hass) # One config entry scenario remove_event = Event( type="node removed", @@ -1670,15 +1671,25 @@ async def test_factory_reset_node( assert notifications[msg_id]["message"].startswith("`Multisensor 6`") assert "with the home ID" not in notifications[msg_id]["message"] async_dismiss(hass, msg_id) + await hass.async_block_till_done() + assert not dev_reg.async_get_device(identifiers={dev_id}) # Add mock config entry to simulate having multiple entries new_entry = MockConfigEntry(domain=DOMAIN) new_entry.add_to_hass(hass) # Re-add the node then remove it again - client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( - client, deepcopy(multisensor_6_state) + add_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(multisensor_6_state), + "result": {}, + }, ) + client.driver.controller.receive_event(add_event) + await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) # Test case where config entry title and home ID don't match @@ -1686,16 +1697,24 @@ async def test_factory_reset_node( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert ( - "network `Mock Title`, with the home ID `3245146787`." + "network `Mock Title`, with the home ID `3245146787`" in notifications[msg_id]["message"] ) async_dismiss(hass, msg_id) # Test case where config entry title and home ID do match hass.config_entries.async_update_entry(integration, title="3245146787") - client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( - client, deepcopy(multisensor_6_state) + add_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(multisensor_6_state), + "result": {}, + }, ) + client.driver.controller.receive_event(add_event) + await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) notifications = async_get_persistent_notifications(hass) From 327016eaebb78eb7f4bbf082393b0efd0fa0ae36 Mon Sep 17 00:00:00 2001 From: vexofp Date: Sat, 9 Dec 2023 17:45:33 -0500 Subject: [PATCH 628/643] Accept HTTP 200 through 206 as success for RESTful Switch (#105358) --- homeassistant/components/rest/switch.py | 4 ++-- tests/components/rest/test_switch.py | 30 +++++++++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 102bb024924..f80143e2f9e 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -171,7 +171,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): try: req = await self.set_device_state(body_on_t) - if req.status_code == HTTPStatus.OK: + if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: self._attr_is_on = True else: _LOGGER.error( @@ -186,7 +186,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): try: req = await self.set_device_state(body_off_t) - if req.status_code == HTTPStatus.OK: + if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: self._attr_is_on = False else: _LOGGER.error( diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 7ded4fb0aed..6224d98f694 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -53,6 +53,22 @@ RESOURCE = "http://localhost/" STATE_RESOURCE = RESOURCE +@pytest.fixture( + params=( + HTTPStatus.OK, + HTTPStatus.CREATED, + HTTPStatus.ACCEPTED, + HTTPStatus.NON_AUTHORITATIVE_INFORMATION, + HTTPStatus.NO_CONTENT, + HTTPStatus.RESET_CONTENT, + HTTPStatus.PARTIAL_CONTENT, + ) +) +def http_success_code(request: pytest.FixtureRequest) -> HTTPStatus: + """Fixture providing different successful HTTP response code.""" + return request.param + + async def test_setup_missing_config( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -262,11 +278,14 @@ async def test_is_on_before_update(hass: HomeAssistant) -> None: @respx.mock -async def test_turn_on_success(hass: HomeAssistant) -> None: +async def test_turn_on_success( + hass: HomeAssistant, + http_success_code: HTTPStatus, +) -> None: """Test turn_on.""" await _async_setup_test_switch(hass) - route = respx.post(RESOURCE) % HTTPStatus.OK + route = respx.post(RESOURCE) % http_success_code respx.get(RESOURCE).mock(side_effect=httpx.RequestError) await hass.services.async_call( SWITCH_DOMAIN, @@ -320,11 +339,14 @@ async def test_turn_on_timeout(hass: HomeAssistant) -> None: @respx.mock -async def test_turn_off_success(hass: HomeAssistant) -> None: +async def test_turn_off_success( + hass: HomeAssistant, + http_success_code: HTTPStatus, +) -> None: """Test turn_off.""" await _async_setup_test_switch(hass) - route = respx.post(RESOURCE) % HTTPStatus.OK + route = respx.post(RESOURCE) % http_success_code respx.get(RESOURCE).mock(side_effect=httpx.RequestError) await hass.services.async_call( SWITCH_DOMAIN, From 64a5271a51c76a8450d334fcd5920f4ef285314e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 10 Dec 2023 08:46:32 +1000 Subject: [PATCH 629/643] Add Tessie Integration (#104684) Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + homeassistant/components/tessie/__init__.py | 60 ++++ .../components/tessie/config_flow.py | 56 ++++ homeassistant/components/tessie/const.py | 21 ++ .../components/tessie/coordinator.py | 84 +++++ homeassistant/components/tessie/entity.py | 45 +++ homeassistant/components/tessie/manifest.json | 10 + homeassistant/components/tessie/sensor.py | 225 ++++++++++++++ homeassistant/components/tessie/strings.json | 92 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/tessie/__init__.py | 1 + tests/components/tessie/common.py | 55 ++++ tests/components/tessie/fixtures/asleep.json | 1 + tests/components/tessie/fixtures/online.json | 276 +++++++++++++++++ .../components/tessie/fixtures/vehicles.json | 292 ++++++++++++++++++ tests/components/tessie/test_config_flow.py | 139 +++++++++ tests/components/tessie/test_coordinator.py | 92 ++++++ tests/components/tessie/test_init.py | 30 ++ tests/components/tessie/test_sensor.py | 24 ++ 22 files changed, 1518 insertions(+) create mode 100644 homeassistant/components/tessie/__init__.py create mode 100644 homeassistant/components/tessie/config_flow.py create mode 100644 homeassistant/components/tessie/const.py create mode 100644 homeassistant/components/tessie/coordinator.py create mode 100644 homeassistant/components/tessie/entity.py create mode 100644 homeassistant/components/tessie/manifest.json create mode 100644 homeassistant/components/tessie/sensor.py create mode 100644 homeassistant/components/tessie/strings.json create mode 100644 tests/components/tessie/__init__.py create mode 100644 tests/components/tessie/common.py create mode 100644 tests/components/tessie/fixtures/asleep.json create mode 100644 tests/components/tessie/fixtures/online.json create mode 100644 tests/components/tessie/fixtures/vehicles.json create mode 100644 tests/components/tessie/test_config_flow.py create mode 100644 tests/components/tessie/test_coordinator.py create mode 100644 tests/components/tessie/test_init.py create mode 100644 tests/components/tessie/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1fe8bf68e78..dad0d51ad79 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1303,6 +1303,8 @@ build.json @home-assistant/supervisor /tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core /homeassistant/components/tesla_wall_connector/ @einarhauks /tests/components/tesla_wall_connector/ @einarhauks +/homeassistant/components/tessie/ @Bre77 +/tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core /tests/components/text/ @home-assistant/core /homeassistant/components/tfiac/ @fredrike @mellado diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py new file mode 100644 index 00000000000..e792780e873 --- /dev/null +++ b/homeassistant/components/tessie/__init__.py @@ -0,0 +1,60 @@ +"""Tessie integration.""" +import logging + +from aiohttp import ClientError, ClientResponseError +from tessie_api import get_state_of_all_vehicles + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import TessieDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tessie config.""" + api_key = entry.data[CONF_ACCESS_TOKEN] + + try: + vehicles = await get_state_of_all_vehicles( + session=async_get_clientsession(hass), + api_key=api_key, + only_active=True, + ) + except ClientResponseError as ex: + # Reauth will go here + _LOGGER.error("Setup failed, unable to connect to Tessie: %s", ex) + return False + except ClientError as e: + raise ConfigEntryNotReady from e + + coordinators = [ + TessieDataUpdateCoordinator( + hass, + api_key=api_key, + vin=vehicle["vin"], + data=vehicle["last_state"], + ) + for vehicle in vehicles["results"] + if vehicle["last_state"] is not None + ] + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Tessie Config.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py new file mode 100644 index 00000000000..c286f43c8b3 --- /dev/null +++ b/homeassistant/components/tessie/config_flow.py @@ -0,0 +1,56 @@ +"""Config Flow for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any + +from aiohttp import ClientConnectionError, ClientResponseError +from tessie_api import get_state_of_all_vehicles +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +TESSIE_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) + + +class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config Tessie API connection.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get configuration from the user.""" + errors: dict[str, str] = {} + if user_input and CONF_ACCESS_TOKEN in user_input: + try: + await get_state_of_all_vehicles( + session=async_get_clientsession(self.hass), + api_key=user_input[CONF_ACCESS_TOKEN], + only_active=True, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + else: + errors["base"] = "unknown" + except ClientConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title="Tessie", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=TESSIE_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py new file mode 100644 index 00000000000..dad9ba2345f --- /dev/null +++ b/homeassistant/components/tessie/const.py @@ -0,0 +1,21 @@ +"""Constants used by Tessie integration.""" +from __future__ import annotations + +from enum import StrEnum + +DOMAIN = "tessie" + +MODELS = { + "model3": "Model 3", + "modelx": "Model X", + "modely": "Model Y", + "models": "Model S", +} + + +class TessieStatus(StrEnum): + """Tessie status.""" + + ASLEEP = "asleep" + ONLINE = "online" + OFFLINE = "offline" diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py new file mode 100644 index 00000000000..7a1efb985ee --- /dev/null +++ b/homeassistant/components/tessie/coordinator.py @@ -0,0 +1,84 @@ +"""Tessie Data Coordinator.""" +from datetime import timedelta +from http import HTTPStatus +import logging +from typing import Any + +from aiohttp import ClientResponseError +from tessie_api import get_state + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import TessieStatus + +# This matches the update interval Tessie performs server side +TESSIE_SYNC_INTERVAL = 10 + +_LOGGER = logging.getLogger(__name__) + + +class TessieDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the Tessie API.""" + + def __init__( + self, + hass: HomeAssistant, + api_key: str, + vin: str, + data: dict[str, Any], + ) -> None: + """Initialize Tessie Data Update Coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Tessie", + update_method=self.async_update_data, + update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), + ) + self.api_key = api_key + self.vin = vin + self.session = async_get_clientsession(hass) + self.data = self._flattern(data) + self.did_first_update = False + + async def async_update_data(self) -> dict[str, Any]: + """Update vehicle data using Tessie API.""" + try: + vehicle = await get_state( + session=self.session, + api_key=self.api_key, + vin=self.vin, + use_cache=self.did_first_update, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.REQUEST_TIMEOUT: + # Vehicle is offline, only update state and dont throw error + self.data["state"] = TessieStatus.OFFLINE + return self.data + # Reauth will go here + raise e + + self.did_first_update = True + if vehicle["state"] == TessieStatus.ONLINE: + # Vehicle is online, all data is fresh + return self._flattern(vehicle) + + # Vehicle is asleep, only update state + self.data["state"] = vehicle["state"] + return self.data + + def _flattern( + self, data: dict[str, Any], parent: str | None = None + ) -> dict[str, Any]: + """Flattern the data structure.""" + result = {} + for key, value in data.items(): + if parent: + key = f"{parent}-{key}" + if isinstance(value, dict): + result.update(self._flattern(value, key)) + else: + result[key] = value + return result diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py new file mode 100644 index 00000000000..4a14522a64c --- /dev/null +++ b/homeassistant/components/tessie/entity.py @@ -0,0 +1,45 @@ +"""Tessie parent entity class.""" + + +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MODELS +from .coordinator import TessieDataUpdateCoordinator + + +class TessieEntity(CoordinatorEntity[TessieDataUpdateCoordinator]): + """Parent class for Tessie Entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + key: str, + ) -> None: + """Initialize common aspects of a Tessie entity.""" + super().__init__(coordinator) + self.vin = coordinator.vin + self.key = key + + car_type = coordinator.data["vehicle_config-car_type"] + + self._attr_translation_key = key + self._attr_unique_id = f"{self.vin}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.vin)}, + manufacturer="Tesla", + configuration_url="https://my.tessie.com/", + name=coordinator.data["display_name"], + model=MODELS.get(car_type, car_type), + sw_version=coordinator.data["vehicle_state-car_version"], + hw_version=coordinator.data["vehicle_config-driver_assist"], + ) + + @property + def value(self) -> Any: + """Return value from coordinator data.""" + return self.coordinator.data[self.key] diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json new file mode 100644 index 00000000000..52fc8dd5be1 --- /dev/null +++ b/homeassistant/components/tessie/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tessie", + "name": "Tessie", + "codeowners": ["@Bre77"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tessie", + "iot_class": "cloud_polling", + "loggers": ["tessie"], + "requirements": ["tessie-api==0.0.9"] +} diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py new file mode 100644 index 00000000000..1941d8ba162 --- /dev/null +++ b/homeassistant/components/tessie/sensor.py @@ -0,0 +1,225 @@ +"""Sensor platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfLength, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN, TessieStatus +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + +PARALLEL_UPDATES = 0 + + +@dataclass +class TessieSensorEntityDescription(SensorEntityDescription): + """Describes Tessie Sensor entity.""" + + value_fn: Callable[[StateType], StateType] = lambda x: x + + +DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( + TessieSensorEntityDescription( + key="state", + options=[status.value for status in TessieStatus], + device_class=SensorDeviceClass.ENUM, + ), + TessieSensorEntityDescription( + key="charge_state-usable_battery_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + TessieSensorEntityDescription( + key="charge_state-charge_energy_added", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="charge_state-charger_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + TessieSensorEntityDescription( + key="charge_state-charger_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state-charger_actual_current", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state-charge_rate", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state-battery_range", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="drive_state-speed", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + ), + TessieSensorEntityDescription( + key="drive_state-power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="drive_state-shift_state", + icon="mdi:car-shift-pattern", + options=["p", "d", "r", "n"], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda x: x.lower() if isinstance(x, str) else x, + ), + TessieSensorEntityDescription( + key="vehicle_state-odometer", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_fl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_fr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_rl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_rr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="climate_state-inside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="climate_state-outside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="climate_state-driver_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="climate_state-passenger_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieSensorEntity(coordinator, description) + for coordinator in coordinators + for description in DESCRIPTIONS + if description.key in coordinator.data + ) + + +class TessieSensorEntity(TessieEntity, SensorEntity): + """Base class for Tessie metric sensors.""" + + entity_description: TessieSensorEntityDescription + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + description: TessieSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.value) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json new file mode 100644 index 00000000000..2069e46cecc --- /dev/null +++ b/homeassistant/components/tessie/strings.json @@ -0,0 +1,92 @@ +{ + "config": { + "error": { + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "Enter your access token from [my.tessie.com/settings/api](https://my.tessie.com/settings/api)." + } + } + }, + "entity": { + "sensor": { + "state": { + "name": "Status", + "state": { + "online": "Online", + "asleep": "Asleep", + "offline": "Offline" + } + }, + "charge_state-usable_battery_level": { + "name": "Battery Level" + }, + "charge_state-charge_energy_added": { + "name": "Charge Energy Added" + }, + "charge_state-charger_power": { + "name": "Charger Power" + }, + "charge_state-charger_voltage": { + "name": "Charger Voltage" + }, + "charge_state-charger_actual_current": { + "name": "Charger Current" + }, + "charge_state-charge_rate": { + "name": "Charge Rate" + }, + "charge_state-battery_range": { + "name": "Battery Range" + }, + "drive_state-speed": { + "name": "Speed" + }, + "drive_state-power": { + "name": "Power" + }, + "drive_state-shift_state": { + "name": "Shift State", + "state": { + "p": "Park", + "d": "Drive", + "r": "Reverse", + "n": "Neutral" + } + }, + "vehicle_state-odometer": { + "name": "Odometer" + }, + "vehicle_state-tpms_pressure_fl": { + "name": "Tyre Pressure Front Left" + }, + "vehicle_state-tpms_pressure_fr": { + "name": "Tyre Pressure Front Right" + }, + "vehicle_state-tpms_pressure_rl": { + "name": "Tyre Pressure Rear Left" + }, + "vehicle_state-tpms_pressure_rr": { + "name": "Tyre Pressure Rear Right" + }, + "climate_state-inside_temp": { + "name": "Inside Temperature" + }, + "climate_state-outside_temp": { + "name": "Outside Temperature" + }, + "climate_state-driver_temp_setting": { + "name": "Driver Temperature Setting" + }, + "climate_state-passenger_temp_setting": { + "name": "Passenger Temperature Setting" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 164aa2acdd2..975bfc60688 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -490,6 +490,7 @@ FLOWS = { "tautulli", "tellduslive", "tesla_wall_connector", + "tessie", "thermobeacon", "thermopro", "thread", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 89c5ee6a80d..33e2229eb2e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5802,6 +5802,12 @@ } } }, + "tessie": { + "name": "Tessie", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tfiac": { "name": "Tfiac", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 1dccc1a5551..d05699120cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2603,6 +2603,9 @@ tesla-powerwall==0.3.19 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 +# homeassistant.components.tessie +tessie-api==0.0.9 + # homeassistant.components.tensorflow # tf-models-official==2.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 57ec5a22df9..afe214608ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1943,6 +1943,9 @@ tesla-powerwall==0.3.19 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 +# homeassistant.components.tessie +tessie-api==0.0.9 + # homeassistant.components.thermobeacon thermobeacon-ble==0.6.0 diff --git a/tests/components/tessie/__init__.py b/tests/components/tessie/__init__.py new file mode 100644 index 00000000000..df17fe027d9 --- /dev/null +++ b/tests/components/tessie/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tessie integration.""" diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py new file mode 100644 index 00000000000..572a687a6e5 --- /dev/null +++ b/tests/components/tessie/common.py @@ -0,0 +1,55 @@ +"""Tessie common helpers for tests.""" + +from http import HTTPStatus +from unittest.mock import patch + +from aiohttp import ClientConnectionError, ClientResponseError +from aiohttp.client import RequestInfo + +from homeassistant.components.tessie.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + +TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN) +TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN) +TEST_VEHICLE_STATE_ASLEEP = load_json_object_fixture("asleep.json", DOMAIN) + +TEST_CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} +TESSIE_URL = "https://api.tessie.com/" + +TEST_REQUEST_INFO = RequestInfo( + url=TESSIE_URL, method="GET", headers={}, real_url=TESSIE_URL +) + +ERROR_AUTH = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.UNAUTHORIZED +) +ERROR_TIMEOUT = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.REQUEST_TIMEOUT +) +ERROR_UNKNOWN = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.BAD_REQUEST +) +ERROR_CONNECTION = ClientConnectionError() + + +async def setup_platform(hass: HomeAssistant, side_effect=None): + """Set up the Tessie platform.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tessie.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + side_effect=side_effect, + ): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/tessie/fixtures/asleep.json b/tests/components/tessie/fixtures/asleep.json new file mode 100644 index 00000000000..4f78efafcf1 --- /dev/null +++ b/tests/components/tessie/fixtures/asleep.json @@ -0,0 +1 @@ +{ "state": "asleep" } diff --git a/tests/components/tessie/fixtures/online.json b/tests/components/tessie/fixtures/online.json new file mode 100644 index 00000000000..8fbab1ab948 --- /dev/null +++ b/tests/components/tessie/fixtures/online.json @@ -0,0 +1,276 @@ +{ + "user_id": 234567890, + "vehicle_id": 345678901, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["beef", "c0ffee"], + "state": "online", + "in_service": false, + "id_s": "123456789", + "calendar_enabled": true, + "api_version": 67, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 75, + "battery_range": 263.68, + "charge_amps": 32, + "charge_current_request": 32, + "charge_current_request_max": 32, + "charge_enable_request": true, + "charge_energy_added": 18.47, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 84, + "charge_miles_added_rated": 84, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 30.6, + "charger_actual_current": 32, + "charger_phases": 1, + "charger_pilot_current": 32, + "charger_power": 7, + "charger_voltage": 224, + "charging_state": "Charging", + "conn_charge_cable": "IEC", + "est_battery_range": 324.73, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 263.68, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 30, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "StartAt", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": 1701216000, + "scheduled_charging_start_time_app": 600, + "scheduled_charging_start_time_minutes": 600, + "scheduled_departure_time": 1694899800, + "scheduled_departure_time_minutes": 450, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0.5, + "timestamp": 1701139037461, + "trip_charging": false, + "usable_battery_level": 75, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": true, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": true, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22.5, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 30.4, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 234, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30.5, + "passenger_temp_setting": 22.5, + "remote_heater_control_enabled": false, + "right_temp_direction": 234, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1701139037461, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": 30.2226265, + "active_route_longitude": -97.6236871, + "active_route_traffic_minutes_delay": 0, + "gps_as_of": 1701129612, + "heading": 185, + "latitude": -30.222626, + "longitude": -97.6236871, + "native_latitude": -30.222626, + "native_location_supported": 1, + "native_longitude": -97.6236871, + "native_type": "wgs", + "power": -7, + "shift_state": null, + "speed": null, + "timestamp": 1701139037461 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1701139037461 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": false, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1701139037461, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 67, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.38.6 c1f85ddb415f", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,7f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": true, + "media_info": { + "audio_volume": 2.3333, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Stopped", + "now_playing_album": "", + "now_playing_artist": "", + "now_playing_duration": 0, + "now_playing_elapsed": 0, + "now_playing_source": "Spotify", + "now_playing_station": "", + "now_playing_title": "" + }, + "media_state": { + "remote_control_enabled": false + }, + "notifications_supported": true, + "odometer": 5454.495383, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 0, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "", + "version": " " + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 74.564543, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1701139037461, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1701062077, + "tpms_last_seen_pressure_time_fr": 1701062047, + "tpms_last_seen_pressure_time_rl": 1701062077, + "tpms_last_seen_pressure_time_rr": 1701062047, + "tpms_pressure_fl": 2.975, + "tpms_pressure_fr": 2.975, + "tpms_pressure_rl": 2.95, + "tpms_pressure_rr": 2.95, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + }, + "display_name": "Test" +} diff --git a/tests/components/tessie/fixtures/vehicles.json b/tests/components/tessie/fixtures/vehicles.json new file mode 100644 index 00000000000..9cc833a1cb2 --- /dev/null +++ b/tests/components/tessie/fixtures/vehicles.json @@ -0,0 +1,292 @@ +{ + "results": [ + { + "vin": "VINVINVIN", + "is_active": true, + "is_archived_manually": false, + "last_charge_created_at": null, + "last_charge_updated_at": null, + "last_drive_created_at": null, + "last_drive_updated_at": null, + "last_idle_created_at": null, + "last_idle_updated_at": null, + "last_state": { + "id": 123456789, + "user_id": 234567890, + "vehicle_id": 345678901, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["beef", "c0ffee"], + "state": "online", + "in_service": false, + "id_s": "123456789", + "calendar_enabled": true, + "api_version": 67, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 75, + "battery_range": 263.68, + "charge_amps": 32, + "charge_current_request": 32, + "charge_current_request_max": 32, + "charge_enable_request": true, + "charge_energy_added": 18.47, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 84, + "charge_miles_added_rated": 84, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 30.6, + "charger_actual_current": 32, + "charger_phases": 1, + "charger_pilot_current": 32, + "charger_power": 7, + "charger_voltage": 224, + "charging_state": "Charging", + "conn_charge_cable": "IEC", + "est_battery_range": 324.73, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 263.68, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 30, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "StartAt", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": 1701216000, + "scheduled_charging_start_time_app": 600, + "scheduled_charging_start_time_minutes": 600, + "scheduled_departure_time": 1694899800, + "scheduled_departure_time_minutes": 450, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0.5, + "timestamp": 1701139037461, + "trip_charging": false, + "usable_battery_level": 75, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": true, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": true, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22.5, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 30.4, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 234, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30.5, + "passenger_temp_setting": 22.5, + "remote_heater_control_enabled": false, + "right_temp_direction": 234, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1701139037461, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": 30.2226265, + "active_route_longitude": -97.6236871, + "active_route_traffic_minutes_delay": 0, + "gps_as_of": 1701129612, + "heading": 185, + "latitude": -30.222626, + "longitude": -97.6236871, + "native_latitude": -30.222626, + "native_location_supported": 1, + "native_longitude": -97.6236871, + "native_type": "wgs", + "power": -7, + "shift_state": null, + "speed": null, + "timestamp": 1701139037461 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1701139037461 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": false, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1701139037461, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 67, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.38.6 c1f85ddb415f", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,7f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": true, + "media_info": { + "audio_volume": 2.3333, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Stopped", + "now_playing_album": "", + "now_playing_artist": "", + "now_playing_duration": 0, + "now_playing_elapsed": 0, + "now_playing_source": "Spotify", + "now_playing_station": "", + "now_playing_title": "" + }, + "media_state": { + "remote_control_enabled": false + }, + "notifications_supported": true, + "odometer": 5454.495383, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 0, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "", + "version": " " + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 74.564543, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1701139037461, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1701062077, + "tpms_last_seen_pressure_time_fr": 1701062047, + "tpms_last_seen_pressure_time_rl": 1701062077, + "tpms_last_seen_pressure_time_rr": 1701062047, + "tpms_pressure_fl": 2.975, + "tpms_pressure_fr": 2.975, + "tpms_pressure_rl": 2.95, + "tpms_pressure_rr": 2.95, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + }, + "display_name": "Test" + } + } + ] +} diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py new file mode 100644 index 00000000000..edf2f8914ae --- /dev/null +++ b/tests/components/tessie/test_config_flow.py @@ -0,0 +1,139 @@ +"""Test the Tessie config flow.""" + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.tessie.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .common import ( + ERROR_AUTH, + ERROR_CONNECTION, + ERROR_UNKNOWN, + TEST_CONFIG, + TEST_STATE_OF_ALL_VEHICLES, +) + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result1["type"] == FlowResultType.FORM + assert not result1["errors"] + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ) as mock_get_state_of_all_vehicles, patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_get_state_of_all_vehicles.mock_calls) == 1 + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Tessie" + assert result2["data"] == TEST_CONFIG + + +async def test_form_invalid_access_token(hass: HomeAssistant) -> None: + """Test invalid auth is handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + side_effect=ERROR_AUTH, + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {CONF_ACCESS_TOKEN: "invalid_access_token"} + + # Complete the flow + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_invalid_response(hass: HomeAssistant) -> None: + """Test invalid auth is handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + side_effect=ERROR_UNKNOWN, + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + # Complete the flow + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_network_issue(hass: HomeAssistant) -> None: + """Test network issues are handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + side_effect=ERROR_CONNECTION, + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Complete the flow + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py new file mode 100644 index 00000000000..43b6489c39d --- /dev/null +++ b/tests/components/tessie/test_coordinator.py @@ -0,0 +1,92 @@ +"""Test the Tessie sensor platform.""" +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL +from homeassistant.components.tessie.sensor import TessieStatus +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .common import ( + ERROR_CONNECTION, + ERROR_TIMEOUT, + ERROR_UNKNOWN, + TEST_VEHICLE_STATE_ASLEEP, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + +from tests.common import async_fire_time_changed + +WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) + + +@pytest.fixture +def mock_get_state(): + """Mock get_state function.""" + with patch( + "homeassistant.components.tessie.coordinator.get_state", + ) as mock_get_state: + yield mock_get_state + + +async def test_coordinator_online(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles online vehciles.""" + + mock_get_state.return_value = TEST_VEHICLE_STATE_ONLINE + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == TessieStatus.ONLINE + + +async def test_coordinator_asleep(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles asleep vehicles.""" + + mock_get_state.return_value = TEST_VEHICLE_STATE_ASLEEP + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == TessieStatus.ASLEEP + + +async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles client errors.""" + + mock_get_state.side_effect = ERROR_UNKNOWN + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == STATE_UNAVAILABLE + + +async def test_coordinator_timeout(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles timeout errors.""" + + mock_get_state.side_effect = ERROR_TIMEOUT + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == TessieStatus.OFFLINE + + +async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles connection errors.""" + + mock_get_state.side_effect = ERROR_CONNECTION + await setup_platform(hass) + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == STATE_UNAVAILABLE diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py new file mode 100644 index 00000000000..409ece97a24 --- /dev/null +++ b/tests/components/tessie/test_init.py @@ -0,0 +1,30 @@ +"""Test the Tessie init.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .common import ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform + + +async def test_load_unload(hass: HomeAssistant) -> None: + """Test load and unload.""" + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_unknown_failure(hass: HomeAssistant) -> None: + """Test init with an authentication failure.""" + + entry = await setup_platform(hass, side_effect=ERROR_UNKNOWN) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_connection_failure(hass: HomeAssistant) -> None: + """Test init with a network connection failure.""" + + entry = await setup_platform(hass, side_effect=ERROR_CONNECTION) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py new file mode 100644 index 00000000000..b9371032d0e --- /dev/null +++ b/tests/components/tessie/test_sensor.py @@ -0,0 +1,24 @@ +"""Test the Tessie sensor platform.""" +from homeassistant.components.tessie.sensor import DESCRIPTIONS +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_sensors(hass: HomeAssistant) -> None: + """Tests that the sensors are correct.""" + + assert len(hass.states.async_all("sensor")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("sensor")) == len(DESCRIPTIONS) + + assert hass.states.get("sensor.test_battery_level").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["battery_level"] + ) + assert hass.states.get("sensor.test_charge_energy_added").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_energy_added"] + ) + assert hass.states.get("sensor.test_shift_state").state == STATE_UNKNOWN From a8148cea65454b79b44ab1c7da15d9b57d39f805 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 9 Dec 2023 23:47:19 +0100 Subject: [PATCH 630/643] Migrate roku tests to use freezegun (#105418) --- tests/components/roku/test_media_player.py | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 5d4568ce7ac..c186741aac9 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError @@ -153,6 +154,7 @@ async def test_availability( hass: HomeAssistant, mock_roku: MagicMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, error: RokuError, ) -> None: """Test entity availability.""" @@ -160,23 +162,22 @@ async def test_availability( future = now + timedelta(minutes=1) mock_config_entry.add_to_hass(hass) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + freezer.move_to(now) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - with patch("homeassistant.util.dt.utcnow", return_value=future): - mock_roku.update.side_effect = error - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE + freezer.move_to(future) + mock_roku.update.side_effect = error + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE future += timedelta(minutes=1) - - with patch("homeassistant.util.dt.utcnow", return_value=future): - mock_roku.update.side_effect = None - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get(MAIN_ENTITY_ID).state == STATE_IDLE + freezer.move_to(future) + mock_roku.update.side_effect = None + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_IDLE async def test_supported_features( From 7b32e4142ebd4be4c2085ead50f06066b2be39ef Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sun, 10 Dec 2023 06:15:48 +0100 Subject: [PATCH 631/643] Make API init async in Minecraft Server (#105403) * Make api init async * Remove duplicate assignment of address and set server to None in constructor --- .../components/minecraft_server/__init__.py | 27 +++++---- .../components/minecraft_server/api.py | 39 +++++++++--- .../minecraft_server/config_flow.py | 6 +- .../minecraft_server/coordinator.py | 12 +++- .../snapshots/test_binary_sensor.ambr | 8 +-- .../snapshots/test_sensor.ambr | 60 +++++++++---------- .../minecraft_server/test_binary_sensor.py | 57 ++++++++++++++---- .../minecraft_server/test_config_flow.py | 8 +-- .../minecraft_server/test_diagnostics.py | 7 ++- .../components/minecraft_server/test_init.py | 34 ++++++++--- .../minecraft_server/test_sensor.py | 52 +++++++++++++--- 11 files changed, 219 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 4e5ab9290f0..0e2debda33e 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -30,13 +30,16 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" - # Check and create API instance. + # Create API instance. + api = MinecraftServer( + hass, + entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + entry.data[CONF_ADDRESS], + ) + + # Initialize API instance. try: - api = await hass.async_add_executor_job( - MinecraftServer, - entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), - entry.data[CONF_ADDRESS], - ) + await api.async_initialize() except MinecraftServerAddressError as error: raise ConfigEntryError( f"Server address in configuration entry is invalid: {error}" @@ -102,9 +105,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_data = config_entry.data # Migrate config entry. + address = config_data[CONF_HOST] + api = MinecraftServer(hass, MinecraftServerType.JAVA_EDITION, address) + try: - address = config_data[CONF_HOST] - MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + await api.async_initialize() host_only_lookup_success = True except MinecraftServerAddressError as error: host_only_lookup_success = False @@ -114,9 +119,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) if not host_only_lookup_success: + address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" + api = MinecraftServer(hass, MinecraftServerType.JAVA_EDITION, address) + try: - address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" - MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + await api.async_initialize() except MinecraftServerAddressError as error: _LOGGER.exception( "Can't migrate configuration entry due to error while parsing server address, try again later: %s", diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py index 4ab7865f369..fc872d37bde 100644 --- a/homeassistant/components/minecraft_server/api.py +++ b/homeassistant/components/minecraft_server/api.py @@ -9,6 +9,8 @@ from dns.resolver import LifetimeTimeout from mcstatus import BedrockServer, JavaServer from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +from homeassistant.core import HomeAssistant + _LOGGER = logging.getLogger(__name__) LOOKUP_TIMEOUT: float = 10 @@ -52,35 +54,51 @@ class MinecraftServerConnectionError(Exception): """Raised when no data can be fechted from the server.""" +class MinecraftServerNotInitializedError(Exception): + """Raised when APIs are used although server instance is not initialized yet.""" + + class MinecraftServer: """Minecraft Server wrapper class for 3rd party library mcstatus.""" - _server: BedrockServer | JavaServer + _server: BedrockServer | JavaServer | None - def __init__(self, server_type: MinecraftServerType, address: str) -> None: + def __init__( + self, hass: HomeAssistant, server_type: MinecraftServerType, address: str + ) -> None: """Initialize server instance.""" + self._server = None + self._hass = hass + self._server_type = server_type + self._address = address + + async def async_initialize(self) -> None: + """Perform async initialization of server instance.""" try: - if server_type == MinecraftServerType.JAVA_EDITION: - self._server = JavaServer.lookup(address, timeout=LOOKUP_TIMEOUT) + if self._server_type == MinecraftServerType.JAVA_EDITION: + self._server = await JavaServer.async_lookup(self._address) else: - self._server = BedrockServer.lookup(address, timeout=LOOKUP_TIMEOUT) + self._server = await self._hass.async_add_executor_job( + BedrockServer.lookup, self._address + ) except (ValueError, LifetimeTimeout) as error: raise MinecraftServerAddressError( - f"Lookup of '{address}' failed: {self._get_error_message(error)}" + f"Lookup of '{self._address}' failed: {self._get_error_message(error)}" ) from error self._server.timeout = DATA_UPDATE_TIMEOUT - self._address = address _LOGGER.debug( - "%s server instance created with address '%s'", server_type, address + "%s server instance created with address '%s'", + self._server_type, + self._address, ) async def async_is_online(self) -> bool: """Check if the server is online, supporting both Java and Bedrock Edition servers.""" try: await self.async_get_data() - except MinecraftServerConnectionError: + except (MinecraftServerConnectionError, MinecraftServerNotInitializedError): return False return True @@ -89,6 +107,9 @@ class MinecraftServer: """Get updated data from the server, supporting both Java and Bedrock Edition servers.""" status_response: BedrockStatusResponse | JavaStatusResponse + if self._server is None: + raise MinecraftServerNotInitializedError() + try: status_response = await self._server.async_status(tries=DATA_UPDATE_RETRIES) except OSError as error: diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index f064a4ac1ef..045133421fb 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -35,10 +35,10 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # Some Bedrock Edition servers mimic a Java Edition server, therefore check for a Bedrock Edition server first. for server_type in MinecraftServerType: + api = MinecraftServer(self.hass, server_type, address) + try: - api = await self.hass.async_add_executor_job( - MinecraftServer, server_type, address - ) + await api.async_initialize() except MinecraftServerAddressError: pass else: diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index f7a60318c64..e498375cafc 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -7,7 +7,12 @@ import logging from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .api import MinecraftServer, MinecraftServerConnectionError, MinecraftServerData +from .api import ( + MinecraftServer, + MinecraftServerConnectionError, + MinecraftServerData, + MinecraftServerNotInitializedError, +) SCAN_INTERVAL = timedelta(seconds=60) @@ -32,5 +37,8 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Get updated data from the server.""" try: return await self._api.async_get_data() - except MinecraftServerConnectionError as error: + except ( + MinecraftServerConnectionError, + MinecraftServerNotInitializedError, + ) as error: raise UpdateFailed(error) from error diff --git a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr index ef03e36343b..2a62fea7f35 100644 --- a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-status_response1] +# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -13,7 +13,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor[java_mock_config_entry-JavaServer-status_response0] +# name: test_binary_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -27,7 +27,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1] +# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -41,7 +41,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-status_response0] +# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/minecraft_server/snapshots/test_sensor.ambr b/tests/components/minecraft_server/snapshots/test_sensor.ambr index fed0ae93c66..b0f77f27b80 100644 --- a/tests/components/minecraft_server/snapshots/test_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -13,7 +13,7 @@ 'state': '5', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -27,7 +27,7 @@ 'state': '3', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -41,7 +41,7 @@ 'state': '10', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -54,7 +54,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -67,7 +67,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -80,7 +80,7 @@ 'state': '123', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Map name', @@ -93,7 +93,7 @@ 'state': 'Dummy Map Name', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Game mode', @@ -106,7 +106,7 @@ 'state': 'Dummy Game Mode', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Edition', @@ -119,7 +119,7 @@ 'state': 'MCPE', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0] +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -133,7 +133,7 @@ 'state': '5', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -152,7 +152,7 @@ 'state': '3', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -166,7 +166,7 @@ 'state': '10', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -179,7 +179,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -192,7 +192,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -205,7 +205,7 @@ 'state': '123', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -219,7 +219,7 @@ 'state': '5', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -233,7 +233,7 @@ 'state': '3', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -247,7 +247,7 @@ 'state': '10', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -260,7 +260,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -273,7 +273,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -286,7 +286,7 @@ 'state': '123', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Map name', @@ -299,7 +299,7 @@ 'state': 'Dummy Map Name', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Game mode', @@ -312,7 +312,7 @@ 'state': 'Dummy Game Mode', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Edition', @@ -325,7 +325,7 @@ 'state': 'MCPE', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0] +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -339,7 +339,7 @@ 'state': '5', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -358,7 +358,7 @@ 'state': '3', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -372,7 +372,7 @@ 'state': '10', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -385,7 +385,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -398,7 +398,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py index 9fae35b113d..4db564bc143 100644 --- a/tests/components/minecraft_server/test_binary_sensor.py +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -22,16 +22,27 @@ from tests.common import async_fire_time_changed @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, snapshot: SnapshotAssertion, @@ -41,7 +52,7 @@ async def test_binary_sensor( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -53,16 +64,27 @@ async def test_binary_sensor( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor_update( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, snapshot: SnapshotAssertion, @@ -73,7 +95,7 @@ async def test_binary_sensor_update( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -88,16 +110,27 @@ async def test_binary_sensor_update( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor_update_failure( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, freezer: FrozenDateTimeFactory, @@ -107,7 +140,7 @@ async def test_binary_sensor_update_failure( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 785905492c1..2a0208f2251 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -41,7 +41,7 @@ async def test_address_validation_failure(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( @@ -58,7 +58,7 @@ async def test_java_connection_failure(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -95,7 +95,7 @@ async def test_java_connection(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -138,7 +138,7 @@ async def test_recovery(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/minecraft_server/test_diagnostics.py b/tests/components/minecraft_server/test_diagnostics.py index 6979325fa0c..80b5c91c1fb 100644 --- a/tests/components/minecraft_server/test_diagnostics.py +++ b/tests/components/minecraft_server/test_diagnostics.py @@ -42,9 +42,14 @@ async def test_config_entry_diagnostics( mock_config_entry = request.getfixturevalue(mock_config_entry) mock_config_entry.add_to_hass(hass) + if server.__name__ == "JavaServer": + lookup_function_name = "async_lookup" + else: + lookup_function_name = "lookup" + # Setup mock entry. with patch( - f"mcstatus.server.{server.__name__}.lookup", + f"mcstatus.server.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"mcstatus.server.{server.__name__}.async_status", diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 018fdac542e..5b0d9509d69 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -122,7 +122,7 @@ async def test_setup_and_unload_entry( java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -138,14 +138,14 @@ async def test_setup_and_unload_entry( assert java_mock_config_entry.state == ConfigEntryState.NOT_LOADED -async def test_setup_entry_failure( +async def test_setup_entry_lookup_failure( hass: HomeAssistant, java_mock_config_entry: MockConfigEntry ) -> None: - """Test failed entry setup.""" + """Test lookup failure in entry setup.""" java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): assert not await hass.config_entries.async_setup( @@ -156,6 +156,24 @@ async def test_setup_entry_failure( assert java_mock_config_entry.state == ConfigEntryState.SETUP_ERROR +async def test_setup_entry_init_failure( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test init failure in entry setup.""" + java_mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_initialize", + side_effect=None, + ): + assert not await hass.config_entries.async_setup( + java_mock_config_entry.entry_id + ) + + await hass.async_block_till_done() + assert java_mock_config_entry.state == ConfigEntryState.SETUP_RETRY + + async def test_setup_entry_not_ready( hass: HomeAssistant, java_mock_config_entry: MockConfigEntry ) -> None: @@ -163,7 +181,7 @@ async def test_setup_entry_not_ready( java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -196,7 +214,7 @@ async def test_entry_migration( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=[ ValueError, # async_migrate_entry JavaServer(host=TEST_HOST, port=TEST_PORT), # async_migrate_entry @@ -258,7 +276,7 @@ async def test_entry_migration_host_only( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -293,7 +311,7 @@ async def test_entry_migration_v3_failure( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=[ ValueError, # async_migrate_entry ValueError, # async_migrate_entry diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py index 006c735e034..7d599669d71 100644 --- a/tests/components/minecraft_server/test_sensor.py +++ b/tests/components/minecraft_server/test_sensor.py @@ -55,17 +55,25 @@ BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -75,6 +83,7 @@ async def test_sensor( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -85,7 +94,7 @@ async def test_sensor( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -98,17 +107,25 @@ async def test_sensor( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, ), @@ -118,6 +135,7 @@ async def test_sensor_disabled_by_default( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -127,7 +145,7 @@ async def test_sensor_disabled_by_default( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -141,17 +159,25 @@ async def test_sensor_disabled_by_default( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -161,6 +187,7 @@ async def test_sensor_update( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -172,7 +199,7 @@ async def test_sensor_update( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -189,17 +216,25 @@ async def test_sensor_update( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -209,6 +244,7 @@ async def test_sensor_update_failure( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -219,7 +255,7 @@ async def test_sensor_update_failure( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", From 1cc47c0553562d295ebec2d9671454824551947a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 10 Dec 2023 17:37:57 +1000 Subject: [PATCH 632/643] Add reauth to Tessie (#105419) * First pass at Tessie * Working POC * async_step_reauth * Config Flow tests * WIP * Add test requirement * correctly gen test requirements * 100% coverage * Remove remnants of copy paste * Add TPMS * Fix docstring * Remove redundant line * Fix some more copied docstrings * Grammar * Create reusable StrEnum * Streamline get * Add a couple more sensors * Removed need for a model * Move MODELS * Remove DOMAIN from config flow * Add translation strings * Remove unused parameter * Simplify error handling * Refactor coordinator to class * Add missing types * Add icon to shift state * Simplify setdefault Co-authored-by: J. Nick Koston * Use walrus for async_unload_platforms Co-authored-by: J. Nick Koston * Reformat entity init Co-authored-by: J. Nick Koston * Remove Unique ID * Better Config Flow Tests * Fix all remaining tests * Standardise docstring * Remove redudnant test * Set TessieDataUpdateCoordinator on entity * Correct some sensors * add error types * Make shift state a ENUM sensor * Add more sensors * Fix translation string * Add precision suggestions * Move session from init to coordinator * Add api_key type * Remove api_key parameter * Meta changes * Update TessieEntity and TessieSensor translations * Goodbye translation keys * bump tessie-api to 0.0.9 * Fix only_active to be True * Per vehicle coordinator * Rework coordinator * Fix coverage * WIP * The grand rework * Add comments * Use ENUM more * Add ENUM translations * Update homeassistant/components/tessie/sensor.py Co-authored-by: J. Nick Koston * Add entity_category * Remove reauth * Remove session * Update homeassistant/components/tessie/__init__.py Co-authored-by: J. Nick Koston * Add property tag * Add error text * Complete config flow tests * Fix property and rename * Fix init test * Reworked coordinator tests * Add extra checks * Update homeassistant/components/tessie/__init__.py Co-authored-by: J. Nick Koston * Update homeassistant/components/tessie/coordinator.py Co-authored-by: J. Nick Koston * Apply suggestions from code review Co-authored-by: J. Nick Koston * Ruff fix * Update homeassistant/components/tessie/config_flow.py Co-authored-by: J. Nick Koston * Remove future ENUMs Co-authored-by: J. Nick Koston * Ruff fix * Update homeassistant/components/tessie/config_flow.py Co-authored-by: J. Nick Koston * Remove reauth and already configured strings * Lowercase sensor values for translation * Update homeassistant/components/tessie/__init__.py Co-authored-by: J. Nick Koston * Fixed, before using lambda * Add value lambda * fix lambda * Fix config flow test * @bdraco fix for 500 errors * format * Add reauth * Reuse string in reauth * Ruff * remove redundant check * Improve error tests --------- Co-authored-by: J. Nick Koston --- homeassistant/components/tessie/__init__.py | 10 +- .../components/tessie/config_flow.py | 48 ++++++++- .../components/tessie/coordinator.py | 5 +- homeassistant/components/tessie/strings.json | 7 ++ tests/components/tessie/test_config_flow.py | 101 ++++++++++++++++++ tests/components/tessie/test_coordinator.py | 12 +++ tests/components/tessie/test_init.py | 9 +- 7 files changed, 185 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index e792780e873..ac77c3cc09e 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -1,4 +1,5 @@ """Tessie integration.""" +from http import HTTPStatus import logging from aiohttp import ClientError, ClientResponseError @@ -7,7 +8,7 @@ from tessie_api import get_state_of_all_vehicles from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -28,9 +29,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_key=api_key, only_active=True, ) - except ClientResponseError as ex: - # Reauth will go here - _LOGGER.error("Setup failed, unable to connect to Tessie: %s", ex) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed from e + _LOGGER.error("Setup failed, unable to connect to Tessie: %s", e) return False except ClientError as e: raise ConfigEntryNotReady from e diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py index c286f43c8b3..4379a810309 100644 --- a/homeassistant/components/tessie/config_flow.py +++ b/homeassistant/components/tessie/config_flow.py @@ -10,6 +10,7 @@ from tessie_api import get_state_of_all_vehicles import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -24,12 +25,16 @@ class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize.""" + self._reauth_entry: ConfigEntry | None = None + async def async_step_user( self, user_input: Mapping[str, Any] | None = None ) -> FlowResult: """Get configuration from the user.""" errors: dict[str, str] = {} - if user_input and CONF_ACCESS_TOKEN in user_input: + if user_input: try: await get_state_of_all_vehicles( session=async_get_clientsession(self.hass), @@ -54,3 +59,44 @@ class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=TESSIE_SCHEMA, errors=errors, ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle re-auth.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get update API Key from the user.""" + errors: dict[str, str] = {} + assert self._reauth_entry + if user_input: + try: + await get_state_of_all_vehicles( + session=async_get_clientsession(self.hass), + api_key=user_input[CONF_ACCESS_TOKEN], + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_access_token" + else: + errors["base"] = "unknown" + except ClientConnectionError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=TESSIE_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 7a1efb985ee..7a2a8c71c56 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -8,6 +8,7 @@ from aiohttp import ClientResponseError from tessie_api import get_state from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -57,7 +58,9 @@ class TessieDataUpdateCoordinator(DataUpdateCoordinator): # Vehicle is offline, only update state and dont throw error self.data["state"] = TessieStatus.OFFLINE return self.data - # Reauth will go here + if e.status == HTTPStatus.UNAUTHORIZED: + # Auth Token is no longer valid + raise ConfigEntryAuthFailed from e raise e self.did_first_update = True diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 2069e46cecc..5d57075241c 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -11,6 +11,13 @@ "access_token": "[%key:common::config_flow::data::access_token%]" }, "description": "Enter your access token from [my.tessie.com/settings/api](https://my.tessie.com/settings/api)." + }, + "reauth_confirm": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "[%key:component::tessie::config::step::user::description%]", + "title": "[%key:common::config_flow::title::reauth%]" } } }, diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py index edf2f8914ae..d1977a13193 100644 --- a/tests/components/tessie/test_config_flow.py +++ b/tests/components/tessie/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant import config_entries from homeassistant.components.tessie.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN @@ -14,8 +16,21 @@ from .common import ( ERROR_UNKNOWN, TEST_CONFIG, TEST_STATE_OF_ALL_VEHICLES, + setup_platform, ) +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_get_state_of_all_vehicles(): + """Mock get_state_of_all_vehicles function.""" + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ) as mock_get_state_of_all_vehicles: + yield mock_get_state_of_all_vehicles + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -137,3 +152,89 @@ async def test_form_network_issue(hass: HomeAssistant) -> None: TEST_CONFIG, ) assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None: + """Test reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + ) + mock_entry.add_to_hass(hass) + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_entry.entry_id, + }, + data=TEST_CONFIG, + ) + + assert result1["type"] == FlowResultType.FORM + assert result1["step_id"] == "reauth_confirm" + assert not result1["errors"] + + with patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_get_state_of_all_vehicles.mock_calls) == 1 + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ERROR_AUTH, {"base": "invalid_access_token"}), + (ERROR_UNKNOWN, {"base": "unknown"}), + (ERROR_CONNECTION, {"base": "cannot_connect"}), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, mock_get_state_of_all_vehicles, side_effect, error +) -> None: + """Test reauth flows that failscript/.""" + + mock_entry = await setup_platform(hass) + mock_get_state_of_all_vehicles.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=TEST_CONFIG, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + + # Complete the flow + mock_get_state_of_all_vehicles.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert "errors" not in result3 + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index 43b6489c39d..8fe92454c36 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from .common import ( + ERROR_AUTH, ERROR_CONNECTION, ERROR_TIMEOUT, ERROR_UNKNOWN, @@ -81,6 +82,17 @@ async def test_coordinator_timeout(hass: HomeAssistant, mock_get_state) -> None: assert hass.states.get("sensor.test_status").state == TessieStatus.OFFLINE +async def test_coordinator_auth(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles timeout errors.""" + + mock_get_state.side_effect = ERROR_AUTH + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + + async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> None: """Tests that the coordinator handles connection errors.""" diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index 409ece97a24..8c12979b9d5 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .common import ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform +from .common import ERROR_AUTH, ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform async def test_load_unload(hass: HomeAssistant) -> None: @@ -16,6 +16,13 @@ async def test_load_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED +async def test_auth_failure(hass: HomeAssistant) -> None: + """Test init with an authentication failure.""" + + entry = await setup_platform(hass, side_effect=ERROR_AUTH) + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_unknown_failure(hass: HomeAssistant) -> None: """Test init with an authentication failure.""" From ff85d0c290707607b6bf0615d81a47cf907671eb Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 10 Dec 2023 09:25:16 +0100 Subject: [PATCH 633/643] Migrate mqtt tests to use freezegun (#105414) --- tests/components/mqtt/test_common.py | 15 +++---- tests/components/mqtt/test_init.py | 67 ++++++++++++++-------------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 0664f6e8d6f..cb5ff53d7e9 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any from unittest.mock import ANY, MagicMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol import yaml @@ -31,6 +32,7 @@ from homeassistant.generated.mqtt import MQTT from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient @@ -1320,9 +1322,8 @@ async def help_test_entity_debug_info_max_messages( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(start_dt): for i in range(0, debug_info.STORED_MESSAGES + 1): async_fire_mqtt_message(hass, "test-topic", f"{i}") @@ -1396,7 +1397,7 @@ async def help_test_entity_debug_info_message( debug_info_data = debug_info.info_for_device(hass, device.id) - start_dt = datetime(2019, 1, 1, 0, 0, 0) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) if state_topic is not None: assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 @@ -1404,8 +1405,7 @@ async def help_test_entity_debug_info_message( "subscriptions" ] - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + with freeze_time(start_dt): async_fire_mqtt_message(hass, str(state_topic), state_payload) debug_info_data = debug_info.info_for_device(hass, device.id) @@ -1426,8 +1426,7 @@ async def help_test_entity_debug_info_message( expected_transmissions = [] if service: # Trigger an outgoing MQTT message - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + with freeze_time(start_dt): if service: service_data = {ATTR_ENTITY_ID: f"{domain}.beer_test"} if service_parameters: diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index d31570548f0..98e2c9b71fe 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -8,6 +8,7 @@ import ssl from typing import Any, TypedDict from unittest.mock import ANY, MagicMock, call, mock_open, patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -40,6 +41,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.dt import utcnow from .test_common import help_all_subscribe_calls @@ -3256,6 +3258,7 @@ async def test_debug_info_wildcard( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3279,10 +3282,9 @@ async def test_debug_info_wildcard( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/abc", "123") + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/abc", "123") debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 @@ -3304,6 +3306,7 @@ async def test_debug_info_filter_same( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info removes messages with same timestamp.""" await mqtt_mock_entry() @@ -3327,14 +3330,13 @@ async def test_debug_info_filter_same( "subscriptions" ] - dt1 = datetime(2019, 1, 1, 0, 0, 0) - dt2 = datetime(2019, 1, 1, 0, 0, 1) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = dt1 - async_fire_mqtt_message(hass, "sensor/abc", "123") - async_fire_mqtt_message(hass, "sensor/abc", "123") - dt_utcnow.return_value = dt2 - async_fire_mqtt_message(hass, "sensor/abc", "123") + dt1 = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + dt2 = datetime(2019, 1, 1, 0, 0, 1, tzinfo=dt_util.UTC) + freezer.move_to(dt1) + async_fire_mqtt_message(hass, "sensor/abc", "123") + async_fire_mqtt_message(hass, "sensor/abc", "123") + freezer.move_to(dt2) + async_fire_mqtt_message(hass, "sensor/abc", "123") debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 @@ -3364,6 +3366,7 @@ async def test_debug_info_same_topic( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3388,10 +3391,9 @@ async def test_debug_info_same_topic( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 @@ -3408,16 +3410,16 @@ async def test_debug_info_same_topic( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) async def test_debug_info_qos_retain( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3441,19 +3443,18 @@ async def test_debug_info_qos_retain( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - # simulate the first message was replayed from the broker with retained flag - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) - # simpulate someone else subscribed and retained messages were replayed - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + # simulate the first message was replayed from the broker with retained flag + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) + # simpulate someone else subscribed and retained messages were replayed + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 From 6a3c422d2f2313d9558f6d2cf1758092320b897c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 10 Dec 2023 13:38:10 +0100 Subject: [PATCH 634/643] Improve Amazon Alexa endpoint validation (#105287) * Improve Amazon Alexa endpoint validation * Add source comment --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/alexa/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 219553b3563..2a9637772b1 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -36,6 +36,15 @@ CONF_FLASH_BRIEFINGS = "flash_briefings" CONF_SMART_HOME = "smart_home" DEFAULT_LOCALE = "en-US" +# Alexa Smart Home API send events gateway endpoints +# https://developer.amazon.com/en-US/docs/alexa/smarthome/send-events.html#endpoints +VALID_ENDPOINTS = [ + "https://api.amazonalexa.com/v3/events", + "https://api.eu.amazonalexa.com/v3/events", + "https://api.fe.amazonalexa.com/v3/events", +] + + ALEXA_ENTITY_SCHEMA = vol.Schema( { vol.Optional(CONF_DESCRIPTION): cv.string, @@ -46,7 +55,7 @@ ALEXA_ENTITY_SCHEMA = vol.Schema( SMART_HOME_SCHEMA = vol.Schema( { - vol.Optional(CONF_ENDPOINT): cv.string, + vol.Optional(CONF_ENDPOINT): vol.All(vol.Lower, vol.In(VALID_ENDPOINTS)), vol.Optional(CONF_CLIENT_ID): cv.string, vol.Optional(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_LOCALE, default=DEFAULT_LOCALE): vol.In( From 063ac53f01c9627b067e0fd8a82ace3725c330e5 Mon Sep 17 00:00:00 2001 From: Florian B Date: Sun, 10 Dec 2023 17:23:05 +0100 Subject: [PATCH 635/643] Fix adding/updating todo items with due date in CalDAV integration (#105435) * refactor: return date/datetime for due date * fix: explicitly set due date on vTODO component Using `set_due` automatically handles converting the Python-native date/datetime values to the correct representation required by RFC5545. * fix: fix tests with changed due date handling * fix: item.due may not be a str * refactor: keep local timezone of due datetime * refactor: reorder import statement To make ruff happy. * fix: fix false-positive mypy error --- homeassistant/components/caldav/todo.py | 10 +++++----- tests/components/caldav/test_todo.py | 13 +++++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index 1bd24dc542a..b7089c3da65 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -98,10 +98,7 @@ def _to_ics_fields(item: TodoItem) -> dict[str, Any]: if status := item.status: item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") if due := item.due: - if isinstance(due, datetime): - item_data["due"] = dt_util.as_utc(due).strftime("%Y%m%dT%H%M%SZ") - else: - item_data["due"] = due.strftime("%Y%m%d") + item_data["due"] = due if description := item.description: item_data["description"] = description return item_data @@ -162,7 +159,10 @@ class WebDavTodoListEntity(TodoListEntity): except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err vtodo = todo.icalendar_component # type: ignore[attr-defined] - vtodo.update(**_to_ics_fields(item)) + updated_fields = _to_ics_fields(item) + if "due" in updated_fields: + todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined] + vtodo.update(**updated_fields) try: await self.hass.async_add_executor_job( partial( diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 6e92f211463..a90529297be 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -1,4 +1,5 @@ """The tests for the webdav todo component.""" +from datetime import UTC, date, datetime from typing import Any from unittest.mock import MagicMock, Mock @@ -200,12 +201,16 @@ async def test_supported_components( ), ( {"due_date": "2023-11-18"}, - {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118"}, + {"status": "NEEDS-ACTION", "summary": "Cheese", "due": date(2023, 11, 18)}, {**RESULT_ITEM, "due": "2023-11-18"}, ), ( {"due_datetime": "2023-11-18T08:30:00-06:00"}, - {"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118T143000Z"}, + { + "status": "NEEDS-ACTION", + "summary": "Cheese", + "due": datetime(2023, 11, 18, 14, 30, 00, tzinfo=UTC), + }, {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, ), ( @@ -311,13 +316,13 @@ async def test_add_item_failure( ), ( {"due_date": "2023-11-18"}, - ["SUMMARY:Cheese", "DUE:20231118"], + ["SUMMARY:Cheese", "DUE;VALUE=DATE:20231118"], "1", {**RESULT_ITEM, "due": "2023-11-18"}, ), ( {"due_datetime": "2023-11-18T08:30:00-06:00"}, - ["SUMMARY:Cheese", "DUE:20231118T143000Z"], + ["SUMMARY:Cheese", "DUE;TZID=America/Regina:20231118T083000"], "1", {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, ), From a7155b154eb873ea43b0f52da3e21b19bf019a25 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Dec 2023 17:27:01 +0100 Subject: [PATCH 636/643] Fix alexa calling not featured cover services (#105444) * Fix alexa calls not supported cover services * Follow up comment and additional tests --- homeassistant/components/alexa/handlers.py | 19 +- tests/components/alexa/test_smart_home.py | 443 ++++++++++++++++----- 2 files changed, 354 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index f99b0231e4d..2796c10795b 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1304,13 +1304,14 @@ async def async_api_set_range( service = None data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} range_value = directive.payload["rangeValue"] + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) # Cover Position if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": range_value = int(range_value) - if range_value == 0: + if supported & cover.CoverEntityFeature.CLOSE and range_value == 0: service = cover.SERVICE_CLOSE_COVER - elif range_value == 100: + elif supported & cover.CoverEntityFeature.OPEN and range_value == 100: service = cover.SERVICE_OPEN_COVER else: service = cover.SERVICE_SET_COVER_POSITION @@ -1319,9 +1320,9 @@ async def async_api_set_range( # Cover Tilt elif instance == f"{cover.DOMAIN}.tilt": range_value = int(range_value) - if range_value == 0: + if supported & cover.CoverEntityFeature.CLOSE_TILT and range_value == 0: service = cover.SERVICE_CLOSE_COVER_TILT - elif range_value == 100: + elif supported & cover.CoverEntityFeature.OPEN_TILT and range_value == 100: service = cover.SERVICE_OPEN_COVER_TILT else: service = cover.SERVICE_SET_COVER_TILT_POSITION @@ -1332,13 +1333,11 @@ async def async_api_set_range( range_value = int(range_value) if range_value == 0: service = fan.SERVICE_TURN_OFF + elif supported & fan.FanEntityFeature.SET_SPEED: + service = fan.SERVICE_SET_PERCENTAGE + data[fan.ATTR_PERCENTAGE] = range_value else: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported and fan.FanEntityFeature.SET_SPEED: - service = fan.SERVICE_SET_PERCENTAGE - data[fan.ATTR_PERCENTAGE] = range_value - else: - service = fan.SERVICE_TURN_ON + service = fan.SERVICE_TURN_ON # Humidifier target humidity elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 7a1abe96110..0a5b1f79f72 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.alexa import smart_home, state_report import homeassistant.components.camera as camera -from homeassistant.components.cover import CoverDeviceClass +from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.config import async_process_ha_core_config @@ -1884,8 +1884,199 @@ async def test_group(hass: HomeAssistant) -> None: ) -async def test_cover_position_range(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("position", "position_attr_in_service_call", "supported_features", "service_call"), + [ + ( + 30, + 30, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ( + 0, + None, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.close_cover", + ), + ( + 99, + 99, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ( + 100, + None, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.open_cover", + ), + ( + 0, + 0, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 60, + 60, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 0, + 0, + CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN, + "cover.set_cover_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_POSITION | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ], + ids=[ + "position_30_open_close", + "position_0_open_close", + "position_99_open_close", + "position_100_open_close", + "position_0_no_open_close", + "position_60_no_open_close", + "position_100_no_open_close", + "position_0_no_close", + "position_100_no_open", + ], +) +async def test_cover_position( + hass: HomeAssistant, + position: int, + position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, +) -> None: """Test cover discovery and position using rangeController.""" + device = ( + "cover.test_range", + "open", + { + "friendly_name": "Test cover range", + "device_class": "blind", + "supported_features": supported_features, + "position": position, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "cover#test_range" + assert appliance["displayCategories"][0] == "INTERIOR_BLIND" + assert appliance["friendlyName"] == "Test cover range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "cover.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Position", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Lower", "Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Raise", "Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "cover#test_range", + service_call, + hass, + payload={"rangeValue": position}, + instance="cover.position", + ) + assert call.data.get("position") == position_attr_in_service_call + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == position + + +async def test_cover_position_range( + hass: HomeAssistant, +) -> None: + """Test cover discovery and position range using rangeController. + + Also tests an invalid cover position being handled correctly. + """ + device = ( "cover.test_range", "open", @@ -1969,59 +2160,6 @@ async def test_cover_position_range(hass: HomeAssistant) -> None: "range": {"minimumValue": 1, "maximumValue": 100}, } in position_state_mappings - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.set_cover_position", - hass, - payload={"rangeValue": 50}, - instance="cover.position", - ) - assert call.data["position"] == 50 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.close_cover", - hass, - payload={"rangeValue": 0}, - instance="cover.position", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 0 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.open_cover", - hass, - payload={"rangeValue": 100}, - instance="cover.position", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "AdjustRangeValue", - "cover#test_range", - "cover.open_cover", - hass, - payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False}, - instance="cover.position", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - call, msg = await assert_request_calls_service( "Alexa.RangeController", "AdjustRangeValue", @@ -3435,8 +3573,159 @@ async def test_presence_sensor(hass: HomeAssistant) -> None: assert {"name": "humanPresenceDetectionState"} in properties["supported"] -async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ( + "tilt_position", + "tilt_position_attr_in_service_call", + "supported_features", + "service_call", + ), + [ + ( + 30, + 30, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.set_cover_tilt_position", + ), + ( + 0, + None, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.close_cover_tilt", + ), + ( + 99, + 99, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.set_cover_tilt_position", + ), + ( + 100, + None, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.open_cover_tilt", + ), + ( + 0, + 0, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 60, + 60, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 0, + 0, + CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.OPEN_TILT, + "cover.set_cover_tilt_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.CLOSE_TILT, + "cover.set_cover_tilt_position", + ), + ], + ids=[ + "tilt_position_30_open_close", + "tilt_position_0_open_close", + "tilt_position_99_open_close", + "tilt_position_100_open_close", + "tilt_position_0_no_open_close", + "tilt_position_60_no_open_close", + "tilt_position_100_no_open_close", + "tilt_position_0_no_close", + "tilt_position_100_no_open", + ], +) +async def test_cover_tilt_position( + hass: HomeAssistant, + tilt_position: int, + tilt_position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, +) -> None: """Test cover discovery and tilt position using rangeController.""" + device = ( + "cover.test_tilt_range", + "open", + { + "friendly_name": "Test cover tilt range", + "device_class": "blind", + "supported_features": supported_features, + "tilt_position": tilt_position, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "cover#test_tilt_range" + assert appliance["displayCategories"][0] == "INTERIOR_BLIND" + assert appliance["friendlyName"] == "Test cover tilt range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "cover.tilt" + + semantics = range_capability["semantics"] + assert semantics is not None + + action_mappings = semantics["actionMappings"] + assert action_mappings is not None + + state_mappings = semantics["stateMappings"] + assert state_mappings is not None + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "cover#test_tilt_range", + service_call, + hass, + payload={"rangeValue": tilt_position}, + instance="cover.tilt", + ) + assert call.data.get("tilt_position") == tilt_position_attr_in_service_call + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == tilt_position + + +async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: + """Test cover discovery and tilt position range using rangeController. + + Also tests and invalid tilt position being handled correctly. + """ device = ( "cover.test_tilt_range", "open", @@ -3485,48 +3774,6 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: ) assert call.data["tilt_position"] == 50 - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_tilt_range", - "cover.close_cover_tilt", - hass, - payload={"rangeValue": 0}, - instance="cover.tilt", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 0 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_tilt_range", - "cover.open_cover_tilt", - hass, - payload={"rangeValue": 100}, - instance="cover.tilt", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "AdjustRangeValue", - "cover#test_tilt_range", - "cover.open_cover_tilt", - hass, - payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False}, - instance="cover.tilt", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - call, msg = await assert_request_calls_service( "Alexa.RangeController", "AdjustRangeValue", From 4752d37df70263979327ea6e8abdcf80414adca4 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 10 Dec 2023 14:09:58 -0800 Subject: [PATCH 637/643] Fix fitbit oauth reauth debug logging (#105450) --- homeassistant/components/fitbit/application_credentials.py | 5 ++++- tests/components/fitbit/test_init.py | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index caf0384eca2..caa47351f45 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -60,7 +60,10 @@ class FitbitOAuth2Implementation(AuthImplementation): resp.raise_for_status() except aiohttp.ClientResponseError as err: if _LOGGER.isEnabledFor(logging.DEBUG): - error_body = await resp.text() if not session.closed else "" + try: + error_body = await resp.text() + except aiohttp.ClientError: + error_body = "" _LOGGER.debug( "Client response error status=%s, body=%s", err.status, error_body ) diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index b6bf75c1c69..3ed3695ff3d 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -107,18 +107,21 @@ async def test_token_refresh_success( @pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize("closing", [True, False]) async def test_token_requires_reauth( hass: HomeAssistant, integration_setup: Callable[[], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, setup_credentials: None, + closing: bool, ) -> None: """Test where token is expired and the refresh attempt requires reauth.""" aioclient_mock.post( OAUTH2_TOKEN, status=HTTPStatus.UNAUTHORIZED, + closing=closing, ) assert not await integration_setup() From b5af987a180a213a74b9ffb3c05ab5acafe808d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 10 Dec 2023 23:16:06 +0100 Subject: [PATCH 638/643] Bump hatasmota to 0.8.0 (#105440) * Bump hatasmota to 0.8.0 * Keep devices with deep sleep support always available * Add tests --- .../components/tasmota/manifest.json | 2 +- homeassistant/components/tasmota/mixins.py | 9 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/tasmota/test_binary_sensor.py | 29 +++++ tests/components/tasmota/test_common.py | 120 ++++++++++++++++++ tests/components/tasmota/test_cover.py | 36 ++++++ tests/components/tasmota/test_fan.py | 27 ++++ tests/components/tasmota/test_light.py | 27 ++++ tests/components/tasmota/test_sensor.py | 38 ++++++ tests/components/tasmota/test_switch.py | 25 ++++ 11 files changed, 313 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 42fc849a2cf..2ce81772774 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.7.3"] + "requirements": ["HATasmota==0.8.0"] } diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index 21030b8c14b..48dbe51fd67 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -112,8 +112,11 @@ class TasmotaAvailability(TasmotaEntity): def __init__(self, **kwds: Any) -> None: """Initialize the availability mixin.""" - self._available = False super().__init__(**kwds) + if self._tasmota_entity.deep_sleep_enabled: + self._available = True + else: + self._available = False async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" @@ -122,6 +125,8 @@ class TasmotaAvailability(TasmotaEntity): async_subscribe_connection_status(self.hass, self.async_mqtt_connected) ) await super().async_added_to_hass() + if self._tasmota_entity.deep_sleep_enabled: + await self._tasmota_entity.poll_status() async def availability_updated(self, available: bool) -> None: """Handle updated availability.""" @@ -135,6 +140,8 @@ class TasmotaAvailability(TasmotaEntity): if not self.hass.is_stopping: if not mqtt_connected(self.hass): self._available = False + elif self._tasmota_entity.deep_sleep_enabled: + self._available = True self.async_write_ha_state() @property diff --git a/requirements_all.txt b/requirements_all.txt index d05699120cd..0a3ddf78dc7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.9.1 # homeassistant.components.tasmota -HATasmota==0.7.3 +HATasmota==0.8.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index afe214608ec..5e5b7bbe613 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ DoorBirdPy==2.1.0 HAP-python==4.9.1 # homeassistant.components.tasmota -HATasmota==0.7.3 +HATasmota==0.8.0 # homeassistant.components.doods # homeassistant.components.generic diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 2bfb4a9d5e2..d5f1e4d7101 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -31,6 +31,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -313,6 +315,21 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + config["swn"][0] = "Test" + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.BINARY_SENSOR, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -323,6 +340,18 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.BINARY_SENSOR, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability when deep sleep is enabled.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + config["swn"][0] = "Test" + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.BINARY_SENSOR, config + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index a184f650fae..1f414cb4e5a 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -4,6 +4,7 @@ import json from unittest.mock import ANY from hatasmota.const import ( + CONF_DEEP_SLEEP, CONF_MAC, CONF_OFFLINE, CONF_ONLINE, @@ -188,6 +189,76 @@ async def help_test_availability_when_connection_lost( assert state.state != STATE_UNAVAILABLE +async def help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + domain, + config, + sensor_config=None, + object_id="tasmota_test", +): + """Test availability after MQTT disconnection when deep sleep is enabled. + + This is a test helper for the TasmotaAvailability mixin. + """ + config[CONF_DEEP_SLEEP] = 1 + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + # Device online + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + # Disconnected from MQTT server -> state changed to unavailable + mqtt_mock.connected = False + await hass.async_add_executor_job(mqtt_client_mock.on_disconnect, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state == STATE_UNAVAILABLE + + # Reconnected to MQTT server -> state no longer unavailable + mqtt_mock.connected = True + await hass.async_add_executor_job(mqtt_client_mock.on_connect, None, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + # Receive LWT again + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_online(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_offline(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async def help_test_availability( hass, mqtt_mock, @@ -236,6 +307,55 @@ async def help_test_availability( assert state.state == STATE_UNAVAILABLE +async def help_test_deep_sleep_availability( + hass, + mqtt_mock, + domain, + config, + sensor_config=None, + object_id="tasmota_test", +): + """Test availability when deep sleep is enabled. + + This is a test helper for the TasmotaAvailability mixin. + """ + config[CONF_DEEP_SLEEP] = 1 + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_online(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_offline(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async def help_test_availability_discovery_update( hass, mqtt_mock, diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index e2bdc8b2ca7..cae65521e21 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -22,6 +22,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -663,6 +665,27 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Test" + config["rl"][0] = 3 + config["rl"][1] = 3 + await help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + Platform.COVER, + config, + object_id="test_cover_1", + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -676,6 +699,19 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Test" + config["rl"][0] = 3 + config["rl"][1] = 3 + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1" + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 2a50e2d43b5..05e3151be2e 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -22,6 +22,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -232,6 +234,20 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["if"] = 1 + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -243,6 +259,17 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["if"] = 1 + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 27b7bd1a82a..50f11fb7757 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -22,6 +22,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -1669,6 +1671,21 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 2 + config["lt_st"] = 1 # 1 channel light (Dimmer) + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.LIGHT, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -1679,6 +1696,16 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.LIGHT, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 2 + config["lt_st"] = 1 # 1 channel light (Dimmer) + await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.LIGHT, config) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 2f50a84ffdd..dc4820779a6 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -28,6 +28,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -1222,6 +1224,26 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + Platform.SENSOR, + config, + sensor_config, + "tasmota_dht11_temperature", + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -1238,6 +1260,22 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_deep_sleep_availability( + hass, + mqtt_mock, + Platform.SENSOR, + config, + sensor_config, + "tasmota_dht11_temperature", + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index 54d94b46fe8..1a16f372fc9 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -20,6 +20,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -158,6 +160,20 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.SWITCH, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -167,6 +183,15 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.SWITCH, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.SWITCH, config) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: From 58d9d0daa5b759424ba399c2437e8bbbda793b60 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Sun, 10 Dec 2023 17:30:24 -0500 Subject: [PATCH 639/643] Add reauth to A. O. Smith integration (#105320) * Add reauth to A. O. Smith integration * Validate reauth uses the same email address * Only show password field during reauth --- .../components/aosmith/config_flow.py | 76 ++++++++++++--- .../components/aosmith/coordinator.py | 5 +- homeassistant/components/aosmith/strings.json | 10 +- tests/components/aosmith/conftest.py | 2 +- tests/components/aosmith/test_config_flow.py | 96 ++++++++++++++++++- 5 files changed, 170 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/aosmith/config_flow.py b/homeassistant/components/aosmith/config_flow.py index 4ee29897070..36a1c215d68 100644 --- a/homeassistant/components/aosmith/config_flow.py +++ b/homeassistant/components/aosmith/config_flow.py @@ -1,6 +1,7 @@ """Config flow for A. O. Smith integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -22,6 +23,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_email: str | None + + def __init__(self): + """Start the config flow.""" + self._reauth_email = None + + async def _async_validate_credentials( + self, email: str, password: str + ) -> str | None: + """Validate the credentials. Return an error string, or None if successful.""" + session = aiohttp_client.async_get_clientsession(self.hass) + client = AOSmithAPIClient(email, password, session) + + try: + await client.get_devices() + except AOSmithInvalidCredentialsException: + return "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return "unknown" + + return None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -32,30 +56,56 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - session = aiohttp_client.async_get_clientsession(self.hass) - client = AOSmithAPIClient( - user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session + error = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] ) - - try: - await client.get_devices() - except AOSmithInvalidCredentialsException: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if error is None: return self.async_create_entry( title=user_input[CONF_EMAIL], data=user_input ) + errors["base"] = error + return self.async_show_form( step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_EMAIL): str, + vol.Required(CONF_EMAIL, default=self._reauth_email): str, vol.Required(CONF_PASSWORD): str, } ), errors=errors, ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth if the user credentials have changed.""" + self._reauth_email = entry_data[CONF_EMAIL] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user's reauth credentials.""" + errors: dict[str, str] = {} + if user_input is not None and self._reauth_email is not None: + email = self._reauth_email + password = user_input[CONF_PASSWORD] + entry_id = self.context["entry_id"] + + if entry := self.hass.config_entries.async_get_entry(entry_id): + error = await self._async_validate_credentials(email, password) + if error is None: + self.hass.config_entries.async_update_entry( + entry, + data=entry.data | user_input, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + errors["base"] = error + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={CONF_EMAIL: self._reauth_email}, + errors=errors, + ) diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py index 80cf85bc59a..bdd144569dd 100644 --- a/homeassistant/components/aosmith/coordinator.py +++ b/homeassistant/components/aosmith/coordinator.py @@ -10,6 +10,7 @@ from py_aosmith import ( ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, FAST_INTERVAL, REGULAR_INTERVAL @@ -29,7 +30,9 @@ class AOSmithCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Fetch latest data from API.""" try: devices = await self.client.get_devices() - except (AOSmithInvalidCredentialsException, AOSmithUnknownException) as err: + except AOSmithInvalidCredentialsException as err: + raise ConfigEntryAuthFailed from err + except AOSmithUnknownException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err mode_pending = any( diff --git a/homeassistant/components/aosmith/strings.json b/homeassistant/components/aosmith/strings.json index 157895e04f8..26de264bab9 100644 --- a/homeassistant/components/aosmith/strings.json +++ b/homeassistant/components/aosmith/strings.json @@ -7,6 +7,13 @@ "password": "[%key:common::config_flow::data::password%]" }, "description": "Please enter your A. O. Smith credentials." + }, + "reauth_confirm": { + "description": "Please update your password for {email}", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -14,7 +21,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py index 509e15024a9..f0ece65d56f 100644 --- a/tests/components/aosmith/conftest.py +++ b/tests/components/aosmith/conftest.py @@ -24,7 +24,7 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, data=FIXTURE_USER_INPUT, - unique_id="unique_id", + unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], ) diff --git a/tests/components/aosmith/test_config_flow.py b/tests/components/aosmith/test_config_flow.py index ff09f23ccbb..5d3e986e05e 100644 --- a/tests/components/aosmith/test_config_flow.py +++ b/tests/components/aosmith/test_config_flow.py @@ -1,15 +1,18 @@ """Test the A. O. Smith config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from py_aosmith import AOSmithInvalidCredentialsException import pytest from homeassistant import config_entries -from homeassistant.components.aosmith.const import DOMAIN -from homeassistant.const import CONF_EMAIL +from homeassistant.components.aosmith.const import DOMAIN, REGULAR_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.aosmith.conftest import FIXTURE_USER_INPUT @@ -82,3 +85,90 @@ async def test_form_exception( assert result3["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] assert result3["data"] == FIXTURE_USER_INPUT assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test reauth works.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_client.get_devices.side_effect = AOSmithInvalidCredentialsException( + "Authentication error" + ) + freezer.tick(REGULAR_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_flow_retry( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test reauth works with retry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_client.get_devices.side_effect = AOSmithInvalidCredentialsException( + "Authentication error" + ) + freezer.tick(REGULAR_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # First attempt at reauth - authentication fails again + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=AOSmithInvalidCredentialsException("Authentication error"), + ): + result2 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # Second attempt at reauth - authentication succeeds + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True): + result3 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" From 77c722630ed8a19b9f29afa11c1a9b53fb2b1210 Mon Sep 17 00:00:00 2001 From: Jan Schneider Date: Sun, 10 Dec 2023 23:59:54 +0100 Subject: [PATCH 640/643] Check if heat area exists when setting up valve opening and battery sensors in moehlenhoff alpha2 (#105437) Check whether the referenced heat area exists when setting up valve opening and battery sensors --- homeassistant/components/moehlenhoff_alpha2/binary_sensor.py | 1 + homeassistant/components/moehlenhoff_alpha2/sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py index 8acc88d8314..5cdca72fa55 100644 --- a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py @@ -27,6 +27,7 @@ async def async_setup_entry( Alpha2IODeviceBatterySensor(coordinator, io_device_id) for io_device_id, io_device in coordinator.data["io_devices"].items() if io_device["_HEATAREA_ID"] + and io_device["_HEATAREA_ID"] in coordinator.data["heat_areas"] ) diff --git a/homeassistant/components/moehlenhoff_alpha2/sensor.py b/homeassistant/components/moehlenhoff_alpha2/sensor.py index e41c6b041f6..2c2e44f451d 100644 --- a/homeassistant/components/moehlenhoff_alpha2/sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry( Alpha2HeatControlValveOpeningSensor(coordinator, heat_control_id) for heat_control_id, heat_control in coordinator.data["heat_controls"].items() if heat_control["INUSE"] - and heat_control["_HEATAREA_ID"] + and heat_control["_HEATAREA_ID"] in coordinator.data["heat_areas"] and heat_control.get("ACTOR_PERCENT") is not None ) From 72c6eb888593cb8c0882d94130cb4823e7c0a045 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 10 Dec 2023 15:46:27 -0800 Subject: [PATCH 641/643] Bump ical to 6.1.1 (#105462) --- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index d7b16ee3bef..f5a24e07b0c 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==6.1.0"] + "requirements": ["ical==6.1.1"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 4c3a8e10a62..335a89eab3c 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==6.1.0"] + "requirements": ["ical==6.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a3ddf78dc7..eb038bc6c2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1064,7 +1064,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.1.0 +ical==6.1.1 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e5b7bbe613..44ec0de5322 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -842,7 +842,7 @@ ibeacon-ble==1.0.1 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.1.0 +ical==6.1.1 # homeassistant.components.ping icmplib==3.0 From c634e3f0ca3baf725beb165b851b537d65e20e84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Dec 2023 20:47:53 -1000 Subject: [PATCH 642/643] Bump zeroconf to 0.128.4 (#105465) * Bump zeroconf to 0.128.3 significant bug fixes changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.128.0...0.128.3 * .4 --- 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 8351212f0b8..6738431b304 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.128.0"] + "requirements": ["zeroconf==0.128.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0a44f93324b..2f1373b61d9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -58,7 +58,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.4 -zeroconf==0.128.0 +zeroconf==0.128.4 # 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 eb038bc6c2b..eea1c6243be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2832,7 +2832,7 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.128.0 +zeroconf==0.128.4 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44ec0de5322..fdce9d3c554 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2127,7 +2127,7 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.128.0 +zeroconf==0.128.4 # homeassistant.components.zeversolar zeversolar==0.3.1 From c89c2f939263327f1683b7d8f534b382efe54bd5 Mon Sep 17 00:00:00 2001 From: Jon Caruana Date: Sun, 10 Dec 2023 23:40:13 -0800 Subject: [PATCH 643/643] Bump pylitejet to v0.6.0 (#105472) --- homeassistant/components/litejet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index 136880257ce..8525bb9ff17 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["pylitejet"], "quality_scale": "platinum", - "requirements": ["pylitejet==0.5.0"] + "requirements": ["pylitejet==0.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index eea1c6243be..97e0f899ad5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1872,7 +1872,7 @@ pylgnetcast==0.3.7 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.5.0 +pylitejet==0.6.0 # homeassistant.components.litterrobot pylitterbot==2023.4.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdce9d3c554..184f0eb8cb5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1416,7 +1416,7 @@ pylaunches==1.4.0 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.5.0 +pylitejet==0.6.0 # homeassistant.components.litterrobot pylitterbot==2023.4.9